diff --git a/CHANGELOG.md b/CHANGELOG.md index 971810f7..90f55717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to ResolutionFlow are documented here. +## [0.1.0.0] - 2026-04-16 + +### Added +- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel +- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown +- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW +- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection +- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete +- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context +- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets` +- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context +- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals +- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources) +- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns +- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state) + +### Fixed +- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions +- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored) +- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch +- Members endpoint auth gate no longer rejects engineers without a PSA mapping +- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions) +- Assignment search and "Load More" removed from resource manager in favor of direct member list + ## [Unreleased] ### Added diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..482e997a --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0.0 diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index 48fa9f57..40346b43 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 @@ -30,6 +33,17 @@ from app.schemas.psa_connection import ( PSABoardResponse, ) from app.core.config import settings +from app.schemas.psa_tickets import ( + PSAResourceSchema, + PSATicketCreatedSchema, + PSATicketStatusUpdateSchema, + TicketCreatePayloadSchema, + PSAPrioritySchema, + TicketListResponseSchema, + AiParseRequestSchema, + AiParseResponseSchema, +) +import app.services.ticket_service as ticket_svc from app.services.psa.encryption import ( decrypt_credentials, encrypt_credentials, @@ -362,33 +376,36 @@ async def list_boards( provider = await get_provider_for_account(current_user.account_id, db) boards = await provider.list_boards() return [PSABoardResponse(id=b.id, name=b.name) for b in boards] - except PSAError: + except PSAError as e: # Boards are optional UI chrome — degrade gracefully rather than surfacing a toast + logger.warning("list_boards failed: %s", e) return [] -@router.get("/tickets/search", response_model=list[PSATicketSearchResult]) +@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)], 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, 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 +424,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,33 +445,250 @@ 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, + status_name=status_name, include_closed=include_closed, 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.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, + 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: + # Resources are optional display data — degrade gracefully rather than surfacing a toast + logger.warning("list_resources(%s) failed: %s", ticket_id, e) + return [] + + +@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 as e: + logger.warning("list_priorities failed: %s", e) + return [] + + @router.get("/tickets/{ticket_id}/context") async def get_ticket_context( ticket_id: int, @@ -561,7 +790,30 @@ async def get_ticket_statuses( except PSANotFoundError: raise HTTPException(status_code=404, detail="Ticket not found") except PSAError as e: - raise HTTPException(status_code=502, detail=str(e)) + logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e) + return [] + + +@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem]) +async def get_board_statuses( + board_id: int, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get available statuses for a service board directly (no ticket lookup required).""" + 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) + statuses = await provider.get_ticket_statuses(board_id) + return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses] + except PSAError as e: + logger.warning("get_board_statuses(%s) failed: %s", board_id, e) + return [] # ── member mapping endpoints ───────────────────────────────────────── @@ -569,7 +821,7 @@ async def get_ticket_statuses( @router.get("/members", response_model=list[PsaMemberResponse]) async def list_members( - current_user: Annotated[User, Depends(require_account_owner)], + current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): """List CW members (from CW API).""" @@ -587,7 +839,9 @@ async def list_members( for m in members ] except PSAError as e: - raise HTTPException(status_code=502, detail=str(e)) + # Members are optional display data — degrade gracefully + logger.warning("list_members failed: %s", e) + return [] @router.get("/member-mappings", response_model=list[PsaMemberMappingResponse]) 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..0bb1565d --- /dev/null +++ b/backend/app/schemas/psa_tickets.py @@ -0,0 +1,65 @@ +"""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 + new_status_id: int + + +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 diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 4832d779..c961b2d1 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -295,6 +295,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/app/services/psa/autotask/provider.py b/backend/app/services/psa/autotask/provider.py index a78fc1c1..3af30de9 100644 --- a/backend/app/services/psa/autotask/provider.py +++ b/backend/app/services/psa/autotask/provider.py @@ -12,6 +12,10 @@ from app.services.psa.types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) @@ -28,7 +32,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( @@ -74,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 a599b064..8f3ab8a8 100644 --- a/backend/app/services/psa/base.py +++ b/backend/app/services/psa/base.py @@ -13,6 +13,10 @@ from .types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) @@ -28,7 +32,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 @@ -83,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/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index af154cdd..d05b7b0e 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, @@ -17,6 +18,10 @@ from app.services.psa.types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) from .client import ConnectWiseClient @@ -55,27 +60,31 @@ 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}'") + # Sanitize: strip single quotes to prevent CW condition injection + safe_query = query.replace("'", "") + conditions.append(f"summary contains '{safe_query}'") if filters.get("board_id"): 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: @@ -86,16 +95,27 @@ class ConnectWiseProvider(PSAProvider): if board_ids: board_list = ", ".join(str(bid) for bid in board_ids) conditions.append(f"board/id in ({board_list})") + if filters.get("company_id"): + conditions.append(f"company/id = {int(filters['company_id'])}") - 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 @@ -246,13 +266,30 @@ class ConnectWiseProvider(PSAProvider): async def update_ticket_status( self, ticket_id: str, status_id: int ) -> PSATicket: - """Update a CW ticket's status using JSON Patch format.""" + """Update a CW ticket's status using JSON Patch format. + + Verifies CW actually applied the change — CW silently returns 200 when + a status id is invalid for the ticket's board. We check the response + body's status.id matches what we sent, and raise PSAError if not. + """ patch_body = [ {"op": "replace", "path": "status", "value": {"id": status_id}} ] data = await self.client.patch( f"/service/tickets/{ticket_id}", json_body=patch_body ) + applied = (data.get("status") or {}) if isinstance(data, dict) else {} + applied_id = applied.get("id") + if applied_id != status_id: + logger.warning( + "CW status PATCH for ticket %s returned status id=%s instead of %s", + ticket_id, applied_id, status_id, + ) + raise PSAError( + f"ConnectWise did not apply status {status_id} " + f"(still {applied.get('name') or applied_id}). " + "The status may not be valid for this ticket's board." + ) return self._map_ticket(data) async def list_members(self) -> list[PSAMember]: @@ -591,16 +628,247 @@ 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 ─────────────────────────────────────────── + + # Schedule type id for "Service Ticket" resources — CW's canonical type for ticket co-assignees + _SCHEDULE_TYPE_SERVICE_TICKET = 4 + + async def _get_ticket_owner(self, ticket_id: int) -> dict | None: + """Fetch the ticket's current owner (MemberReference) or None if unassigned.""" + data = await self.client.get( + f"/service/tickets/{ticket_id}", + params={"fields": "id,owner"}, + ) + if not isinstance(data, dict): + return None + owner_raw = data.get("owner") + return owner_raw if isinstance(owner_raw, dict) and owner_raw.get("id") else None + + async def _list_ticket_schedule_entries(self, ticket_id: int) -> list[dict]: + """List schedule entries for a ticket's co-assignees. + + Returns raw CW schedule entry dicts with at least id and member info. + """ + data = await self.client.get( + "/schedule/entries", + params={ + "conditions": ( + f"type/id={self._SCHEDULE_TYPE_SERVICE_TICKET} AND objectId={ticket_id}" + ), + "fields": "id,member,name", + "pageSize": 100, + }, + ) + return data if isinstance(data, list) else [] + + async def list_resources(self, ticket_id: int) -> list[PSAResource]: + """List members assigned to a CW ticket. + + Merges the `owner` MemberReference (primary assignee) with schedule entries + of type 4 (Service Ticket resources — co-assignees). Deduped by member id. + """ + owner = await self._get_ticket_owner(ticket_id) + entries = await self._list_ticket_schedule_entries(ticket_id) + members = await self.list_members() + by_id = {str(m.id): m for m in members} + + seen_ids: set[str] = set() + results: list[PSAResource] = [] + + if owner is not None: + owner_id = str(owner.get("id")) + m = by_id.get(owner_id) + if m: + results.append(PSAResource( + member_id=int(m.id), + member_name=m.name, + member_identifier=m.identifier, + )) + else: + results.append(PSAResource( + member_id=int(owner.get("id") or 0), + member_name=str(owner.get("name") or ""), + member_identifier=str(owner.get("identifier") or ""), + )) + seen_ids.add(owner_id) + + for entry in entries: + entry_member = entry.get("member") if isinstance(entry, dict) else None + if not isinstance(entry_member, dict): + continue + mid = str(entry_member.get("id") or "") + if not mid or mid in seen_ids: + continue + m = by_id.get(mid) + if m: + results.append(PSAResource( + member_id=int(m.id), + member_name=m.name, + member_identifier=m.identifier, + )) + else: + results.append(PSAResource( + member_id=int(entry_member.get("id") or 0), + member_name=str(entry_member.get("name") or ""), + member_identifier=str(entry_member.get("identifier") or ""), + )) + seen_ids.add(mid) + + return results + + async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: + """Assign a member to a CW ticket. + + - If the ticket has no owner, set the target as `owner` (CW's canonical + primary assignee field). CW typically mirrors this into the derived + `resources` string automatically. + - If the ticket is already owned by someone else, add the target as a + co-assignee via a schedule entry of type 4 (Service Ticket). The + existing owner is not changed. + - Idempotent when target is already owner or already has a schedule entry. + """ + members = await self.list_members() + target = next((m for m in members if str(m.id) == str(member_id)), None) + if target is None: + raise PSAError(f"Member {member_id} not found") + + current_owner = await self._get_ticket_owner(ticket_id) + + if current_owner is None: + # Primary assign — set owner + await self.client.patch( + f"/service/tickets/{ticket_id}", + json_body=[{"op": "replace", "path": "owner", "value": {"id": int(target.id)}}], + ) + elif str(current_owner.get("id")) != str(target.id): + # Ticket owned by someone else — add as co-assignee via schedule entry. + # Idempotent: skip if a schedule entry already exists for this member. + existing = await self._list_ticket_schedule_entries(ticket_id) + already_assigned = any( + str((e.get("member") or {}).get("id") or "") == str(target.id) + for e in existing + ) + if not already_assigned: + await self.client.post( + "/schedule/entries", + json_body={ + "member": {"id": int(target.id)}, + "objectId": int(ticket_id), + "type": {"id": self._SCHEDULE_TYPE_SERVICE_TICKET}, + "name": target.name or target.identifier or f"Member {target.id}", + }, + ) + # else: already the owner — idempotent no-op + + return PSAResource( + 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). + + - If the target is the current owner, clear the owner field. + - Otherwise, delete their schedule entry (Service Ticket type). + """ + members = await self.list_members() + target = next((m for m in members if str(m.id) == str(member_id)), None) + if target is None: + return + + current_owner = await self._get_ticket_owner(ticket_id) + + if current_owner is not None and str(current_owner.get("id")) == str(target.id): + # Unassign the owner. Try RFC 6902 "remove" first; fall back to + # "replace" with null if CW rejects it. + try: + await self.client.patch( + f"/service/tickets/{ticket_id}", + json_body=[{"op": "remove", "path": "owner"}], + ) + except PSAError: + await self.client.patch( + f"/service/tickets/{ticket_id}", + json_body=[{"op": "replace", "path": "owner", "value": None}], + ) + return + + # Not the owner — find and delete the schedule entry for this member. + entries = await self._list_ticket_schedule_entries(ticket_id) + for entry in entries: + entry_member = entry.get("member") if isinstance(entry, dict) else None + if isinstance(entry_member, dict) and str(entry_member.get("id") or "") == str(target.id): + entry_id = entry.get("id") + if entry_id: + await self.client.delete(f"/schedule/entries/{entry_id}") + break + + # ── 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 []) + ] diff --git a/backend/app/services/psa/halopsa/provider.py b/backend/app/services/psa/halopsa/provider.py index 4a917ed2..d8f2573e 100644 --- a/backend/app/services/psa/halopsa/provider.py +++ b/backend/app/services/psa/halopsa/provider.py @@ -12,6 +12,10 @@ from app.services.psa.types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) @@ -28,7 +32,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( @@ -74,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 c051a5f7..2ac841b5 100644 --- a/backend/app/services/psa/types.py +++ b/backend/app/services/psa/types.py @@ -73,6 +73,40 @@ class PSABoard(BaseModel): inactive: bool = False +class PaginatedTicketResult(BaseModel): + items: list[PSATicket] + total: int + page: int + 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" diff --git a/backend/app/services/ticket_service.py b/backend/app/services/ticket_service.py new file mode 100644 index 00000000..542c0b46 --- /dev/null +++ b/backend/app/services/ticket_service.py @@ -0,0 +1,116 @@ +"""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, + new_status_id=status_id, + ) + + +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 + ], + ) 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 diff --git a/docs/LESSONS-ARCHIVE.md b/docs/LESSONS-ARCHIVE.md index d5c04d06..94995335 100644 --- a/docs/LESSONS-ARCHIVE.md +++ b/docs/LESSONS-ARCHIVE.md @@ -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`. diff --git a/docs/connectwise/CW_Security_Roles/README.md b/docs/connectwise/CW_Security_Roles/README.md new file mode 100644 index 00000000..28c12dff --- /dev/null +++ b/docs/connectwise/CW_Security_Roles/README.md @@ -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. diff --git a/docs/connectwise/CW_Security_Roles/Security_Roles_Matrix_11132017.xlsx b/docs/connectwise/CW_Security_Roles/Security_Roles_Matrix_11132017.xlsx new file mode 100644 index 00000000..d7f4adf3 Binary files /dev/null and b/docs/connectwise/CW_Security_Roles/Security_Roles_Matrix_11132017.xlsx differ diff --git a/docs/connectwise/CW_Security_Roles/api-member-security-roles.md b/docs/connectwise/CW_Security_Roles/api-member-security-roles.md new file mode 100644 index 00000000..8b3276c5 --- /dev/null +++ b/docs/connectwise/CW_Security_Roles/api-member-security-roles.md @@ -0,0 +1,1373 @@ +# ConnectWise API Member — Security Roles Reference + +_Generated 2026-04-16 from `Security_Roles_Matrix_11132017.xlsx`. Do not edit by hand — update the XLSX and re-run `generate_role_docs.py`._ + +## How to read this document + +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: + +| Level | Meaning | +|-------|---------| +| `ALL` | Access to all records in the system. | +| `MY` | Access to records owned by the user's team. | +| `MINE` | Access only to records owned by the user. | +| `NONE` | No access. | + +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. + +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. + +## Table of contents + +- [Companies](#companies) — 15 actions +- [Finance](#finance) — 12 actions +- [Marketing](#marketing) — 4 actions +- [Procurement](#procurement) — 10 actions +- [Project](#project) — 16 actions +- [Sales](#sales) — 8 actions +- [Service Desk](#service-desk) — 19 actions +- [System](#system) — 16 actions +- [Time and Expense](#time-and-expense) — 6 actions + +## Companies + +### Company Maintenance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create companies within the system. | +| Add | `NONE` | Retricts the ability to create companies within the system. | +| Edit | `ALL` | Allows the ability to edit existing companies within the system. | +| Edit | `NONE` | Retricts the ability to edit existing companies within the system. | +| Delete | `ALL` | Allows the ability to delete existing companies within the system. | +| Delete | `NONE` | Retricts the ability to delete existing companies within the system. | +| Inquire | `ALL` | Allows the ability to review existing companies within the system. | +| Inquire | `NONE` | Retricts the ability to review existing companies within the system. | + +### Company/Contact Group Maintenance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create/add Group information on the Groups Tab of a company. | +| Add | `MY` | Not Applicable. | +| Add | `NONE` | Retricts the ability to create/add Group information on the Groups Tab of a company. | +| Edit | `ALL` | Allows the ability to edit existing Group information on the Groups Tab of a company. | +| Edit | `MY` | Not Applicable. | +| Edit | `NONE` | Retricts the ability to edit existing Group information on the Groups Tab of a company. | +| Delete | `ALL` | Allows the ability to delete existing Group information on the Groups Tab of a company. | +| Delete | `MY` | Not Applicable. | +| Delete | `NONE` | Retricts the ability to delete existing Group information on the Groups Tab of a company. | +| Inquire | `ALL` | Allows the ability to review Group information on the Groups Tab of a company. | +| Inquire | `MY` | Not Applicable. | +| Inquire | `NONE` | Retricts the ability to review Group information on the Groups Tab of a company. | + +### Configuration - Display Passwords + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | When reviewing configurations at the company level, Custom Configuration Questions that are labeled as "Password" in the Configuratoin Type Setup Table will be visible. | +| Inquire | `NONE` | When reviewing configurations at the company level, Custom Configuration Questions that are labeled as "Password" in the Configuration Type Setup Table will be encrypted. | + +### Configurations + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create configurations within the system. | +| Add | `NONE` | Retricts the ability to create configurations within the system. | +| Edit | `ALL` | Allows the ability to edit existing configurations within the system. | +| Edit | `NONE` | Retricts the ability to edit existing configurations within the system. | +| Delete | `ALL` | Allows the ability to delete existing configurations within the system. | +| Delete | `NONE` | Retricts the ability to delete existing configurations within the system. | +| Inquire | `ALL` | Allows the ability to review existing configurations within the system. | +| Inquire | `NONE` | Retricts the ability to review existing configurations within the system. | + +### Contacts + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create contacts within the system. | +| Add | `NONE` | Retricts the ability to create contacts within the system. | +| Edit | `ALL` | Allows the ability to edit existing contacts within the system. | +| Edit | `NONE` | Retricts the ability to edit existing contacts within the system. | +| Delete | `ALL` | Allows the ability to delete existing contacts within the system. | +| Delete | `NONE` | Retricts the ability to delete existing contacts within the system. | +| Inquire | `ALL` | Allows the ability to review existing contacts within the system. | +| Inquire | `NONE` | Retricts the ability to review existing contacts within the system. | + +### CRM/Sales Activities + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add new activities. | +| Add | `MY` | Allows the ability to add new activities (same as ALL). | +| Add | `NONE` | Restricts the ability to add new activities. | +| Edit | `ALL` | Allows the ability to edit all existing activities within the system. | +| Edit | `MY` | Allows the ability to edit only the activities that belong to a particular member. An activity belongs to me if I created it, if I assigned someone to it, or if I am assigned to it. | +| Edit | `NONE` | Retricts the ability to edit existing activities within the system. | +| Delete | `ALL` | Allows the ability to delete all existing activities within the system. | +| Delete | `MY` | Allows the ability to delete activities that belong to a particular member. An activity belongs to me if I created it, if I assigned someone to it, or if I am assigned to it. | +| Delete | `NONE` | Retricts the ability to delete existing activities within the system. | +| Inquire | `ALL` | Allows the ability to review all existing activities within the system. | +| Inquire | `MY` | Allows the ability to review only the activities that belong to a particular member. An activity belongs to me if I created it, if I assigned someone to it, or if I am assigned to it. | +| Inquire | `NONE` | Retricts the ability to review existing activities within the system. NOTE: If set to "None" the My Activities Screen will no longer be visible. | + +### Lead Import + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | — | _Not sure why this has a My / All option. It only controls whether or not I can see the Import Contacts menu icon._ | + +### Manage Documents + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add documents within the system. | +| Add | `MY` | Allows the ability to add documents that particular member uploaded within the system. | +| Add | `NONE` | Retricts the ability to add documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. | +| Edit | `ALL` | Allows the ability to edit all existing documents within the system. | +| Edit | `MY` | Allows the ability to edit only the documents that particular member uploaded within the system. | +| Edit | `NONE` | Retricts the ability to edit existing documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. | +| Delete | `ALL` | Allows the ability to delete all existing documents within the system. | +| Delete | `MY` | Allows the ability to delete only the documents that particular member uploaded within the system. | +| Delete | `NONE` | Retricts the ability to delete existing documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. | +| Inquire | `ALL` | Allows the ability to review all existing documents within the system. | +| Inquire | `MY` | Allows the ability to review only the documents that particular member uploaded within the system. | +| Inquire | `NONE` | Retricts the ability to review existing documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. | + +### Management + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add information on the Management Tab of a company. | +| Add | `NONE` | Retricts the ability to add information on the Management Tab of a company. | +| Edit | `ALL` | Allows the ability to edit existing information on the Management Tab of a company. | +| Edit | `NONE` | Retricts the ability to edit existing information on the Management Tab of a company. | +| Delete | `ALL` | Allows the ability to delete existing information on the Management Tab of a company. | +| Delete | `NONE` | Retricts the ability to delete existing information on the Management Tab of a company. | +| Inquire | `ALL` | Allows the ability to review existing information on the Management Tab of a company. | +| Inquire | `NONE` | Retricts the ability to review existing information on the Management Tab of a company. | + +### Notes + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add information on the Notes Tab of a company or contact. | +| Add | `NONE` | Retricts the ability to add information on the Notes Tab of a company or contact. | +| Edit | `ALL` | Allows the ability to edit existing information on the Notes Tab of a company or contact. | +| Edit | `NONE` | Retricts the ability to edit existing information on the Notes Tab of a company or contact. | +| Delete | `ALL` | Allows the ability to delete existing information on the Notes Tab of a company or contact. | +| Delete | `NONE` | Retricts the ability to delete existing information on the Notes Tab of a company or contact. | +| Inquire | `ALL` | Allows the ability to review information on the Notes Tab of a company or contact. | +| Inquire | `NONE` | Retricts the ability to review information on the Notes Tab of a company or contact. | + +### Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Companies Category of the reports module. By default, when this option is selected, access to all company and contact reports is allows, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Companies and Contacts Category of the reports module. | + +### Surveys + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. | +| Add | `NONE` | Retricts the ability to add information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. | +| Edit | `ALL` | Allows the ability to edit information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. | +| Edit | `NONE` | Retricts the ability to edit information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. | +| Delete | `ALL` | Allows the ability to delete information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. | +| Delete | `NONE` | Retricts the ability to delete information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. | +| Inquire | `ALL` | Allows the ability to review information on the Surveys Tab of a company or contact. This applies to both CRM and Service Surveys. | +| Inquire | `NONE` | Retricts the ability to review information on the Surveys Tab of a company or contact. This applies to both CRM and Service Surveys. | + +### Team Members + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add Team Role information on the Team Tab of a company or contact. | +| Add | `NONE` | Retricts the ability to add Team Role information on the Team Tab of a company or contact. | +| Edit | `ALL` | Allows the ability to edit existing Team Role information on the Team Tab of a company or contact. | +| Edit | `NONE` | Retricts the ability to edit existing Team Role information on the Team Tab of a company or contact. | +| Delete | `ALL` | Allows the ability to delete existing Team Role information on the Team Tab of a company or contact. | +| Delete | `NONE` | Retricts the ability to delete existing Team Role information on the Team Tab of a company or contact. | +| Inquire | `ALL` | Allows the ability to review existing Team Role information on the Team Tab of a company or contact. | +| Inquire | `NONE` | Retricts the ability to review existing Team Role information on the Team Tab of a company or contact. | + +### Tracks + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add Track Items to the Tracks Tab of a company or contact. | +| Add | `NONE` | Retricts the ability to add Track Items to the Tracks Tab of a company or contact. | +| Edit | `ALL` | Allows the ability to edit existing Track Items to the Tracks Tab of a company or contact. | +| Edit | `NONE` | Retricts the ability to edit existing Track Items to the Tracks Tab of a company or contact. | +| Delete | `ALL` | Allows the ability to delete existing Track Items to the Tracks Tab of a company or contact. | +| Delete | `NONE` | Retricts the ability to delete existing Track Items to the Tracks Tab of a company or contact. | +| Inquire | `ALL` | Allows the ability to review Track Items to the Tracks Tab of a company or contact. | +| Inquire | `NONE` | Retricts the ability to review Track Items to the Tracks Tab of a company or contact. | + +### UserCentric + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not Applicable. See Inquire Level._ | +| Edit | — | _Not Applicable. See Inquire Level._ | +| Delete | — | _Not Applicable. See Inquire Level._ | +| Inquire | `ALL` | Allows access to the UserCentric Icon under the Companies Module. | +| Inquire | `NONE` | Retricts access to the UserCentric Icon under the Companies Module. | + +## Finance + +### Accounting Interface + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | `ALL` | Allows the ability to delete GL Batches from the Open Batches tab and to Remove Records on the Unposted Invoices, Unposted Expenses and Unposted Procurement tabs. | +| Delete | `NONE` | Restricts the ability to delete GL Batches from the Open Batches tab and to Remove Records on the Unposted Invoices, Unposted Expenses and Unposted Procurement tabs. | +| Inquire | `ALL` | Allows access to the Accounting Interface Screen. | +| Inquire | `NONE` | Retricts access to the Accounting Interface Screen. | + +### Agreement Invoicing + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create agreement invoices within the system. | +| Add | `NONE` | Retricts the ability to create agreement invoices within the system. | +| Edit | `ALL` | Allows the ability to edit existing agreement invoices within the system. | +| Edit | `NONE` | Retricts the ability to edit existing agreement invoices within the system. | +| Delete | `ALL` | Allows the ability to delete existing agreement invoices within the system. | +| Delete | `NONE` | Retricts the ability to delete existing agreement invoices within the system. | +| Inquire | `ALL` | Allows the ability to review existing agreement invoices within the system. | +| Inquire | `NONE` | Retricts the ability to review existing agreement invoices within the system. | + +### Agreement Sales + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire and Edit levels._ | +| Edit | `ALL` | Allows the use of the actions on the Agreement Sales screen. | +| Edit | `NONE` | Restricts the ability to use the actions on the Agreement Sales screen. | +| Delete | — | _Not applicable. See Inquire and Edit levels._ | +| Inquire | `ALL` | Allows access to the Agreement Sales screen. | +| Inquire | `NONE` | Restricts access to the Agreement Sales screen. | + +### Agreements + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create agreements within the system. | +| Add | `NONE` | Retricts the ability to create agreements within the system. | +| Edit | `ALL` | Allows the ability to edit existing agreements within the system. This also includes all tabs on the agreement. | +| Edit | `NONE` | Retricts the ability to edit existing agreements within the system. This also includes all tabs on the agreement. | +| Delete | `ALL` | Allows the ability to delete existing agreements within the system. | +| Delete | `NONE` | Retricts the ability to delete existing agreements within the system. | +| Inquire | `ALL` | Allows the ability to review existing agreements within the system. | +| Inquire | `NONE` | Retricts the ability to review existing agreements within the system. | + +### Billing Rate Maintenance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add custom work roles (agreements, companies). | +| Add | `NONE` | Restricts the ability to add custom work roles (agreements, companies). | +| Edit | `ALL` | Allows the ability to edit custom work roles (agreements, companies). | +| Edit | `NONE` | Restricts the ability to edit custom work roles (agreements, companies). | +| Delete | `ALL` | Allows the ability to delete custom work roles (agreements, companies). | +| Delete | `NONE` | Restricts the ability to delete custom work roles (agreements, companies). | +| Inquire | `ALL` | Allows the ability to view custom work roles (agreements, companies). | +| Inquire | `NONE` | Restricts the ability to view custom work roles (agreements, companies). | + +### Billing view Time + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire level._ | +| Edit | `ALL` | Enables you to edit the Billing Options pod. | +| Edit | `NONE` | Restricts the ability to view the Billing Options pod on a time or expense entry. | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to view billing options section on a time or expense entry. | +| Inquire | `NONE` | Restricts the ability to view billing options section on a time or expense entry. | + +### Company Finance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to edit the Company Finance Screen information for companies within the system. | +| Edit | `NONE` | Retricts the ability to edit the Company Finance Screen information for companies within the system. | +| Delete | — | _Not Applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to review the Company Finance Screen for companies within the system. | +| Inquire | `NONE` | Retricts the ability to review the Company Finance Screen for companies within the system. | + +### Expense Reimbursement + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows access to the Expense Reimbursement Screen. | +| Inquire | `NONE` | Retricts access to the Expense Reimbursement Screen. | + +### Financial Dashboard + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not Applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review the Financial Dashboard. | +| Inquire | `NONE` | Retricts the ability to review the Financial Dashboard. | + +### Invoice Approval + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to approve (route) any invoices within the system. | +| Edit | `MY` | Allows the ability to approve (route) any invoices that are currently routed to the particular member. | +| Edit | `NONE` | Restricts the ability to approve (route) invoices within the system. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Restricts access to email invoices via the Invoice Batch Emailing screen and also approvals via the My Invoices Screen. | +| Inquire | `MY` | Same as ALL. | + +### Invoicing + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create invoices within the system. | +| Add | `MY` | Allows the ability to create invoices within the system (same as ALL). | +| Add | `NONE` | Retricts the ability to create invoices within the system. | +| Edit | `ALL` | Allows the ability to edit all existing invoices within the system. | +| Edit | `MY` | Allows the ability to edit only the existing invoices that are routed to a particular member in the system. | +| Edit | `NONE` | Retricts the ability to edit existing invoices within the system. | +| Delete | `ALL` | Allows the ability to delete all existing invoices within the system. | +| Delete | `MY` | Allows the ability to delete only the existing invoices that are routed to a particular member within the system. | +| Delete | `NONE` | Retricts the ability to delete existing invoices within the system. | +| Inquire | `ALL` | Allows the ability to view all existing invoices in the system. | +| Inquire | `MY` | Allows the ability to view only the existing invoices that are routed to a particular member within the system. | +| Inquire | `NONE` | Retricts the ability to review the Invoicing, Invoice Search, or Special Invoices screens. | + +### Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Finance Category of the reports module. By default, when this option is selected, access to all company and contact reports is allows, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Finance Category of the reports module. | + +## Marketing + +### ConnectWise Campaign + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to view ConnectWise Campaign in the left nacigation. | +| Inquire | `NONE` | Retricts the ability to view or interact with ConnectWise Campaign. | + +### Marketing Groups + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new Groups. | +| Add | `NONE` | Restricts the ability to create new Groups. | +| Edit | `ALL` | Allows the ability to edit existing Groups. | +| Edit | `NONE` | Restricts the ability to edit existing Groups. | +| Delete | `ALL` | Allows the ability to delete existing Groups. | +| Delete | `NONE` | Restricts the ability to delete existing Groups. | +| Inquire | `ALL` | Allows the ability to access the Marketing Groups screen. | +| Inquire | `NONE` | Restricts the ability to access the Marketing Groups screen. | + +### Marketing Management + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create marketing items located within the Marketing Module. | +| Add | `MY` | Allows the ability to create marketing items located within the Marketing Module only for those items that member is the Owner. | +| Add | `NONE` | Retricts the ability to create marketing items located within the Marketing Module. | +| Edit | `ALL` | Allows the ability to edit marketing items located within the Marketing Module. | +| Edit | `MY` | Allows the ability to edit marketing items located within the Marketing Module only for those items that member is the Owner. | +| Edit | `NONE` | Retricts the ability to edit marketing items located within the Marketing Module. | +| Delete | `ALL` | Allows the ability to delete marketing items located within the Marketing Module. | +| Delete | `MY` | Allows the ability to delete marketing items located within the Marketing Module only for those items that member is the Owner. | +| Delete | `NONE` | Retricts the ability to delete marketing items located within the Marketing Module. | +| Inquire | `ALL` | Allows the ability to review marketing items located within the Marketing Module. | +| Inquire | `MY` | Allows the ability to review marketing items located within the Marketing Module only for those items that member is the Owner. | +| Inquire | `NONE` | Retricts the ability to review marketing items located within the Marketing Module. | + +### Marketing Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Marketing Category of the reports module. By default, when this option is selected, access to all marketing reports is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Marketing Category of the reports module. | + +## Procurement + +### Inventory Adjustments + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new Inventory Adjustments. | +| Add | `NONE` | Retricts the ability to create new Inventory Adjustments. | +| Edit | `ALL` | Allows the ability to edit existing open Inventory Adjustment item information. | +| Edit | `NONE` | Retricts the ability to edit existing Inventory Adjustment item information. | +| Delete | `ALL` | Allows the ability to delete existing open Inventory Adjustment items. | +| Delete | `NONE` | Retricts the ability to delete existing Inventory Adjustment items. | +| Inquire | `ALL` | Allows the ability to review existing Inventory Adjustment item information. | +| Inquire | `NONE` | Retricts the ability to review existing Inventory Adjustment item information. | + +### Inventory Transfers + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to complete a Inventory Transfer. | +| Edit | `NONE` | Retricts the ability to complete a Inventory Transfer. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to review existing Inventory Transfer information. | +| Inquire | `NONE` | Retricts the ability to review existing Inventory Transfer information. | + +### Product Catalog + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new Products in the Product Catalog. | +| Add | `NONE` | Restricts the ability to create new products in the Product Catalog. | +| Edit | `ALL` | Allows the ability to edit existing products in the Products Catalog. | +| Edit | `NONE` | Restricts the ability to edit existing products in the Products Catalog. | +| Delete | `ALL` | Allows the ability to delete existing Products in the Products Catalog. | +| Delete | `NONE` | Restricts the ability to delete existing Products in the Products Catalog. | +| Inquire | `ALL` | Allows the ability to review existing Product information in the Product Catalog. | +| Inquire | `NONE` | Restricts the ability to review existing Product information in the Product Catalog. | + +### Products + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add Products to Opportunities, Sales Orders, and Service Tickets. | +| Add | `NONE` | Retricts the ability to add Products to Opportunities, Sales Orders, and Service Tickets. NOTE: This option also controls the ability to pick and ship products. | +| Edit | `ALL` | Allows the ability to edit Products on Opportunities, Sales Orders, Service Tickets, and Invoices. | +| Edit | `NONE` | Restricts the ability to edit Products on Opportunities, Sales Orders, Service Tickets, and Invoices. | +| Delete | `ALL` | Allows the ability to delete products added to Opportunities, Sales Orders, Service Tickets, and Invoices. | +| Delete | `NONE` | Restricts the ability to delete products added to Opportunities, Sales Orders, Service Tickets, and Invoices. | +| Inquire | `ALL` | Allows the ability to review information for products added to Opportunities, Sales Orders, and Service Tickets. | +| Inquire | `NONE` | Retricts the ability to review information for products added to Opportunities, Sales Orders, and Service Tickets. | + +### Purchase Orders + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new POs. | +| Add | `MY` | Allows the ability to create new POs. | +| Add | `NONE` | Restricts the ability to create new POs. | +| Edit | `ALL` | Allows the ability to edit existing POs. | +| Edit | `MY` | Allows the ability to edit existing PO's where the Member is listed in the Entered By field on the PO. | +| Edit | `NONE` | Restricts the ability to edit existing POs. | +| Delete | `ALL` | Allows the ability to delete existing POs. | +| Delete | `MY` | Allows the ability to delete existing PO's where the Member is listed in the Entered By field on the PO. | +| Delete | `NONE` | Restricts the ability to delete existing POs. | +| Inquire | `ALL` | Allows the ability to view existing POs. | +| Inquire | `MY` | Allows the ability to delete existing PO's where the Member is listed in the Entered By field on the PO. | +| Inquire | `NONE` | Restricts the ability to view existing POs. | + +### Purchasing Approvals + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to use actionable options for items in the Purchasing Approvals Search Screen. | +| Edit | `NONE` | Retricts the ability to use actionable options for items in the Purchasing Approvals Search Screen. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to review information for items in the Purchasing Approvals Search Screen. | +| Inquire | `NONE` | Retricts the ability to review information for items in the Purchasing Approvals Search Screen. | + +### Purchasing Demand + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to use actionable options for items in the Purchasing Search Screen. | +| Edit | `NONE` | Retricts the ability to use actionable options for items in the Purchasing Search Screen. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to review information for items in the Purchasing Search Screen. | +| Inquire | `NONE` | Retricts the ability to review information for items in the Purchasing Search Screen. | + +### Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Procurement Category of the reports module. By default, when this option is selected, access to all procurement reports is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Procurement Category of the reports module. | + +### RMA Entry + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new RMA Tags. | +| Add | `NONE` | Restricts the ability to create new RMA Tags. | +| Edit | `ALL` | Allows the ability to edit existing RMA Tags. | +| Edit | `NONE` | Restricts the ability to edit existing RMA Tags. | +| Delete | `ALL` | Allows the ability to delete existing RMA Tags. | +| Delete | `NONE` | Restricts the ability to delete existing RMA Tags. | +| Inquire | `ALL` | Allows the ability to view existing RMA Tags. | +| Inquire | `NONE` | Restricts the ability to view existing RMA Tags. | + +### RMA Processing + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to view vendor, shipping, and closing sections of RMA tags. | +| Inquire | `NONE` | Restricts the ability to view vendor, shipping, and closing sections of RMA tags. | + +## Project + +### Close Project Tickets + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit level._ | +| Edit | `ALL` | Allows the ability to close and re-open Projects and Project Tickets. | +| Edit | `NONE` | Retricts the ability to close and re-open Projects and Project Tickets. | +| Delete | — | _Not applicable. See Edit level._ | +| Inquire | — | _Not applicable. See Edit level._ | + +### Close Projects + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to set existing Projects to a closed status. | +| Edit | `NONE` | Disallows the ability to set existing Projects to a closed status. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to view Closed statuses on the Project tab of a Project. | +| Inquire | `NONE` | Disallows the ability to view Closed statuses on the Project tab of a Project. | + +### Project Contacts + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add contact information to the Contacts Tab of existing projects. | +| Add | `MY` | The ability to add contact information to the Contacts Tab for projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to add contact information to the Contacts Tab of existing projects. | +| Edit | `ALL` | Allows the ability to edit contact information to the Contacts Tab of existing projects. | +| Edit | `MY` | The ability to edit contact information on the Contacts Tab for projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit contact information to the Contacts Tab of existing projects. | +| Delete | `ALL` | Allows the ability to delete contact information to the Contacts Tab of existing projects. | +| Delete | `MY` | The ability to delete contact information from the Contacts Tab for projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to delete contact information to the Contacts Tab of existing projects. | +| Inquire | `ALL` | Allows the ability to review contact information to the Contacts Tab of existing projects. | +| Inquire | `MY` | The ability to review contact information on the Contacts Tab of projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Retricts the ability to review contact information to the Contacts Tab of existing projects. | + +### Project Finance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to edit the Project Finance, Recap, and Invoice tabs of existing projects. | +| Edit | `MY` | Access to the Finance, Recap, and Invoices tabs of the project or project ticket is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Restricts the ability to edit the Project Finance, Recap, and Invoice tabs of existing projects. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to review the Project Finance, Recap, and Invoice tabs of existing projects. Also allows access to the Project budget by Variance view in the Views tab. | +| Inquire | `MY` | The ability to review the Finance, Recap or Invoices Tabs of the project or project ticket is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Restricts the ability to review the Project Finance, Recap, and Invoice tabs of existing projects. Also restricts access to the Project budget by Variance view in the Views tab. NOTE: If you select None, the Project Boards icon will no longer be visible. In order to see the Project Board icon, the user needs Project Management Inquire set to All, AND Project Finance Inquire set to My at the very least. | + +### Project Headers + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add new projects. | +| Add | `MY` | The ability to add new projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Restricts the ability to add new projects. | +| Edit | `ALL` | Allows the ability to edit the general tab of existing projects. | +| Edit | `MY` | The ability to edit the general tab of existing projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Restricts the ability to edit the general tab of existing projects. | +| Delete | `ALL` | Allows the ability to delete existing projects. | +| Delete | `MY` | The ability to delete existing projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Restricts the ability to delete existing projects. | +| Inquire | `ALL` | Allows the ability to view existing projects. | +| Inquire | `MY` | The ability to view existing projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Restricts the ability to view existing projects. | + +### Project Management + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to view Project Boards. | +| Inquire | `MY` | Not Applicable. | +| Inquire | `NONE` | Retricts the ability to view Project Boards. NOTE: The only available options are All or None. If you select None, the Project Boards icon will no longer be visible. In order to see the Project Board icon, the user needs Project Management Inquire set to All, AND Project Finance Inquire set to My at the very least. | + +### Project Notes + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add information on the Notes Tab of a Project. | +| Add | `MY` | The ability to add information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to add information on the Notes Tab of a Project. | +| Edit | `ALL` | Allows the ability to edit information on the Notes Tab of a Project. | +| Edit | `MY` | The ability to edit information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit information on the Notes Tab of a Project. | +| Delete | `ALL` | Allows the ability to delete information on the Notes Tab of a Project. | +| Delete | `MY` | The ability to delete information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to delete information on the Notes Tab of a Project. | +| Inquire | `ALL` | Allows the ability to review information on the Notes Tab of a Project. | +| Inquire | `MY` | The ability to view information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Retricts the ability to review information on the Notes Tab of a Project. | + +### Project Phase + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add a new Phase on the Workplan Tab of a Project. | +| Add | `MY` | The ability to add a new Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to add a new Phase on the Workplan Tab of a Project. | +| Edit | `ALL` | Allows the ability to edit an existing Phase on the Workplan Tab of a Project. | +| Edit | `MY` | The ability to edit a Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit an existing Phase on the Workplan Tab of a Project. | +| Delete | `ALL` | Allows the ability to delete an existing Phase on the Workplan Tab of a Project. | +| Delete | `MY` | The ability to delete a Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to delete an existing Phase on the Workplan Tab of a Project. | +| Inquire | `ALL` | Allows the ability to review an existing Phase on the Workplan Tab of a Project. | +| Inquire | `MY` | The ability to view a Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Retricts the ability to review an existing Phase on the Workplan Tab of a Project. | + +### Project Product + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add product items to the ProductsTab of existing projects. | +| Add | `MY` | The ability to add product items to the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to add product items to the ProductsTab of existing projects. | +| Edit | `ALL` | Allows the ability to edit product items to the ProductsTab of existing projects. | +| Edit | `MY` | The ability to edit product items on the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit product items to the ProductsTab of existing projects. | +| Delete | `ALL` | Allows the ability to delete product items to the ProductsTab of existing projects. | +| Delete | `MY` | The ability to delete product items from the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to delete product items to the ProductsTab of existing projects. | +| Inquire | `ALL` | Allows the ability to review product items to the ProductsTab of existing projects. | +| Inquire | `MY` | The ability to view product items on the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Retricts the ability to review product items to the ProductsTab of existing projects. | + +### Project Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Project Category of the reports module. By default, when this option is selected, access to all marketing reports is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Project Category of the reports module. | + +### Project Scheduling + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add information on the Schedule Tab of an all existing Projects. | +| Add | `MY` | The ability to add information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to add information on the Schedule Tab of a Project. | +| Edit | `ALL` | Allows the ability to edit the information stored on the Schedule Tab of an all existing Projects. | +| Edit | `MY` | The ability to edit information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit the information stored on the Schedule Tab of a Project. | +| Delete | `ALL` | Allows the ability to delete the information stored on the Schedule Tab of an all existing Projects. | +| Delete | `MY` | The ability to delete information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to delete the information stored on the Schedule Tab of a Project. | +| Inquire | `ALL` | Allows the ability to review the information stored on the Schedule Tab of an all existing Projects. | +| Inquire | `MY` | The ability to view information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Retricts the review the information stored on the Schedule Tab of a Project. | + +### Project Teams + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add members on the Project Team Tab of an all existing Projects. Also allows the ability to convert Project tickets to Service Tickets. | +| Add | `MY` | The ability to add members on the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to add members on the Project Team Tab of a Project. | +| Edit | `ALL` | Allows the ability to edit members on the Project Team Tab of a Project. | +| Edit | `MY` | The ability to edit members on the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit members on the Project Team Tab of a Project. | +| Delete | `ALL` | Allows the ability to delete members on the Project Team Tab of a Project. | +| Delete | `MY` | The ability to delete members from the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to delete members on the Project Team Tab of a Project. | +| Inquire | `ALL` | Allows the ability to review member details on the Project Team Tab of a Project. Also allows the ability to convert Project tickets to Service Tickets. | +| Inquire | `MY` | The ability to view members on the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Retricts the ability to review member details on the Project Team Tab of a Project. | + +### Project Templates + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create Project Templates. | +| Add | `NONE` | Retricts the ability to create Project Templates. | +| Edit | `ALL` | Allows the ability to edit Project Templates. | +| Edit | `NONE` | Retricts the ability to edit Project Templates. | +| Delete | `ALL` | Allows the ability to delete Project Templates. | +| Delete | `NONE` | Retricts the ability to delete Project Templates. | +| Inquire | `ALL` | Allows the ability to review Project Templates. | +| Inquire | `NONE` | Retricts the ability to review Project Templates. | + +### Project Ticket - Dependencies + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to append predecessor tickets to a project ticket. | +| Add | `NONE` | Disallows the ability to append predecessor tickets to a project ticket. | +| Edit | `ALL` | Allows the ability to edit the existing predecessor ticket on a project ticket. | +| Edit | `NONE` | Disallows the ability to edit the existing predecessor ticket on a project ticket. | +| Delete | `ALL` | Allows the ability to remove an existing predecessor ticket on a project ticket. | +| Delete | `NONE` | Disallows the ability to remove an existing predecessor ticket on a project ticket. | +| Inquire | `ALL` | Allows the ability to view existing predecessor ticket info on a project ticket. | +| Inquire | `NONE` | Disallows the ability to view existing predecessor ticket info on a project ticket. | + +### Project Ticket Tasks + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add tasks to Project Tickets. | +| Add | `MY` | The ability to add tasks to Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to add tasks to Project Tickets. | +| Edit | `ALL` | Allows the ability to edit tasks on Project Tickets. | +| Edit | `MY` | The ability to edit tasks on Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit tasks on Project Tickets. | +| Delete | `ALL` | Allows the ability to delete tasks on Project Tickets. | +| Delete | `MY` | The ability to delete tasks from Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to delete tasks on Project Tickets. | +| Inquire | `ALL` | Allows the ability to review tasks on Project Tickets. | +| Inquire | `MY` | The ability to review tasks on Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. | +| Inquire | `NONE` | Retricts the ability to review tasks on Project Tickets. | + +### Project Tickets + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create Project Tickets. | +| Add | `MY` | The ability to create project tickets is determined by the settings of the project role the member is assigned to on the specific project. | +| Add | `NONE` | Retricts the ability to create Project Tickets. | +| Edit | `ALL` | Allows the ability to edit existing Project Tickets. | +| Edit | `MY` | The ability to edit Project Tickets is determined by the settings of the project role the member is assigned to on the specific project. | +| Edit | `NONE` | Retricts the ability to edit existing Project Ticket information. | +| Delete | `ALL` | Allows the ability to delete existing Project Tickets. | +| Delete | `MY` | The ability to delete existing Project Tickets is determined by the settings of the project role the member is assigned to on the specific project. | +| Delete | `NONE` | Retricts the ability to review existing delete existing Project Tickets. | +| Inquire | `ALL` | Allows the ability to review existing Project Ticket information. Allows the ability to view Project Tickets and Issues on the My List screen. | +| Inquire | `MY` | The ability to review Project Tickets is determned by the settings of the project role the member is assigned to on the specific project. Allows the ability to view Project Tickets and Issues on the My List screen. | +| Inquire | `NONE` | Retricts the ability to review existing Project Ticket information. NOTE: Controls the ability to view the Project and Issues tabs on the My List screen | + +## Sales + +### Closed Opportunity + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit level._ | +| Edit | `ALL` | Allows the ability to close and re-open opportunities. | +| Edit | `NONE` | Restricts the ability to close and re-open opportunities. | +| Delete | — | _Not applicable. See Edit level._ | +| Inquire | — | _Not applicable. See Edit level._ | + +### Opportunity + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create Opportunities. | +| Add | `MY` | Allows the ability to create Opportunities. | +| Add | `NONE` | Retricts the ability to create Opportunities. | +| Edit | `ALL` | Allows the ability to edit existing Opportunities. | +| Edit | `MY` | Allows the ability to edit only those Opportunities for which the member is part of the team or is the teammate of a member of the team that has the Allow Access checkbox checked. | +| Edit | `NONE` | Retricts the ability to edit existing Opportunities. | +| Delete | `ALL` | Allows the ability to delete existing Opportunities. | +| Delete | `MY` | Allows the ability to edit only those Opportunities for which the member is part of the team or is the teammate of a member on the team that has the Allow Access checkbox checked. | +| Delete | `NONE` | Retricts the ability to delete existing Opportunities. | +| Inquire | `ALL` | Allows the ability to review existing Opportunities. | +| Inquire | `MY` | Allows the ability to edit only those Opportunities for which the member is part of the team or is the teammate of a member of the team that has the Allow Access checkbox checked. | +| Inquire | `NONE` | Retricts the ability to review existing Opportunities. | + +### Opportunity Finance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review the Finance Tab of Opportunities. | +| Inquire | `NONE` | Retricts the ability to review the Finance Tab of Opportunities. | + +### Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Sales Category of the reports module. By default, when this option is selected, access to all marketing reports is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Sales Category of the reports module. | + +### Sales Dashboard + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review the Sales Overview screen. | +| Inquire | `NONE` | Retricts the ability to review the Sales Overview screen. | + +### Sales Funnel + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review the Sales Funnel screen. | +| Inquire | `NONE` | Retricts the ability to review the Sales Funnel screen. | + +### Sales Order Finance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review the Finance Tab of Opportunities. | +| Inquire | `NONE` | Restricts the ability to review the Finance Tab of Opportunities. NOTE: The ability to edit the Finance tab of a Sales Order is based on the security persmissions for this role in the Sales Order field. | + +### Sales Orders + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create Sales Orders. | +| Add | `NONE` | Retricts the ability to create Sales Orders. | +| Edit | `ALL` | Allows the ability to edit existing Sales Orders. | +| Edit | `NONE` | Retricts the ability to edit existing Sales Orders. | +| Delete | `ALL` | Allows the ability to delete existing Sales Orders. | +| Delete | `NONE` | Retricts the ability to delete existing Sales Orders. | +| Inquire | `ALL` | Allows the ability to review existing Sales Orders. | +| Inquire | `NONE` | Retricts the ability to review existing Sales Orders. | + +## Service Desk + +### Agile Board + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to view and use the Agile board. | +| Inquire | `NONE` | Retricts the ability to view and use the Agile board. | + +### ChatAssist + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows access to the ChatAssist Icon under the Service Desk Module. | +| Inquire | `NONE` | Retricts access to the ChatAssist Icon under the Service Desk Module. | + +### Close Service Tickets + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows users to set a new ticket to a closed status before saving it for the first time. | +| Add | `NONE` | Restricts the ability to set a new ticket to a closed status before saving it for the first time. | +| Edit | `ALL` | Allows the ability to close and re-open service tickets. | +| Edit | `NONE` | Restricts the ability to close and re-open service tickets. | +| Delete | — | _Not Applicable. See Add, Edit, and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to view closed statuses in Service Ticket Status dropdown | +| Inquire | `NONE` | Restricts the ability to view closed statuses in Service Ticket Status dropdown. | + +### CloudConsole + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows access to the CloudConsole Icon under the Service Desk Module. | +| Inquire | `NONE` | Retricts access to the CloudConsole Icon under the Service Desk Module. | + +### ConnectWise Control + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Enables the ScreenConnect Button on tickets allowing the ability to create a ScreenConnect session. | +| Inquire | `NONE` | Disables the ScreenConnect Button on tickets disallowing the ability to create a ScreenConnect session. | + +### ConnectWise Manage Network + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Add and Inquire levels._ | +| Edit | — | _Not applicable. See Add and Inquire levels._ | +| Delete | `ALL` | Allows the ability to remove holds from the ConnectWise Manage Network. | +| Delete | `NONE` | Restricts the ability to remove holds from the ConnectWise Manage Network. | +| Inquire | `ALL` | Allows access to the ConnectWise Manage Network. | +| Inquire | `NONE` | Retricts access to the ConnectWise Manage Network. | + +### Knowledge Base Approver + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit, Delete, and Inquire levels._ | +| Edit | `ALL` | Allows the ability to approve and reject Knowledge Base articles that are pending approval. | +| Edit | `NONE` | Restricts the ability to approve and reject Knowledge Base articles that are pending approval. | +| Delete | `ALL` | Allows the ability to delete Knowledge Base articles in any status created by any member. | +| Delete | `NONE` | Restricts the ability to delete any Knowledge Base articles. | +| Inquire | `ALL` | Allows the ability to review Knowledge Base articles in any status. NONEL Restricts the ability to review any Knowledge Base articles. | + +### Knowledge Base Creator + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add draft Knowledge Base articles and submit for approval. | +| Add | `MY` | Allows the ability to add draft Knowledge Base articles and submit for approval. | +| Add | `NONE` | Restricts the ability to add draft Knowledge Base articles and submit for approval. | +| Edit | `ALL` | Allows the ability to edit Knowledge Base articles in Draft, Rejected, and Revise status for any member. | +| Edit | `MY` | Allows the ability to edit Knowledge Base articles in Draft, Rejected, and Revise status created by the logged in member. | +| Edit | `NONE` | Restricts the ability to edit Knowledge Base articles. | +| Delete | `ALL` | Allows the ability to delete Knowledge Base articles in Draft, Rejected, and Revise status created by any member. | +| Delete | `MY` | Allows the ability to delete Knowledge Base articles in Draft, Rejected, and Revise status created by the logged in member. | +| Delete | `NONE` | Restricts the ability to delete Knowledge Base articles. | +| Inquire | `ALL` | Allows the ability to review Knowledge Base articles created by any member. | +| Inquire | `MY` | Allows the ability for a member to review Knowledge Base articles created by the logged in member. | +| Inquire | `NONE` | Restricts the ability to review any Knowledge Base articles. | + +### Launch Remote Access + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to launch Automate or Control access from any ticket. | +| Inquire | `MY` | Allows the ability to launch Automate or Control access from any ticket the member is a resource on. | +| Inquire | `NONE` | Restricts the ability to launch Automate or Control access. Hides the Launch column in the Configuration pod. | + +### Merge Tickets + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to merge tickets. | +| Add | `NONE` | Restricts the ability to merge tickets | +| Edit | `ALL` | Allows for Open merged tickets to be set to a Closed status. | +| Edit | `NONE` | Restricts the ability to merge tickets | +| Delete | — | _Not applicable. See Add, Edit, and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to view the relationship of merged tickets. | +| Inquire | `NONE` | Restricts the ability to view the relationship of merged tickets. | + +### Print Service Signoff + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to print the Signoff form for any service ticket. | +| Inquire | `MY` | Allows the ability to print the Signoff form for tickets you are a resource on, tickets you have assigned another resource on, and tickets created by you. | +| Inquire | `NONE` | Restricts the ability to print the Signoff form for all tickets. | + +### Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Service Desk Category of the reports module. By default, when this option is selected, access to all Service reports is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Service Desk Category of the reports module. | + +### Resource Scheduling + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add resources on Service Tickets and Activities. | +| Add | `MY` | Allows the ability for a member to add themselves as a resource on a Service Ticket or Activity. | +| Add | `NONE` | Retricts the ability to add resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. | +| Edit | `ALL` | Allows the ability to edit resources on Service Tickets and Activities. | +| Edit | `MY` | Allows the ability for a member to edit themselves as a resource on a Service Ticket or Activity. | +| Edit | `NONE` | Retricts the ability to edit resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. | +| Delete | `ALL` | Allows the ability to delete resources on Service Tickets and Activities. | +| Delete | `MY` | Allows the ability for a member to delete themselves as a resource on a Service Ticket or Activity. | +| Delete | `NONE` | Retricts the ability to delete resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. | +| Inquire | `ALL` | Allows the ability to review resources on Service Tickets and Activities. | +| Inquire | `MY` | Allows the ability for a member to review only their resource records on a Service Ticket or Activity. | +| Inquire | `NONE` | Retricts the ability to review resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. | + +### Service Dashboard + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows access to the Service Dashboard under the Service Desk Module. | +| Inquire | `NONE` | Retricts access to the Service Dashboard under the Service Desk Module. | + +### Service Ticket - Dependencies + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to append predecessor tickets to a service ticket. | +| Add | `NONE` | Disallows the ability to append predecessor tickets to a service ticket. | +| Edit | `ALL` | Allows the ability to edit the existing predecessor ticket on a service ticket. | +| Edit | `NONE` | Disallows the ability to edit the existing predecessor ticket on a service ticket. | +| Delete | `ALL` | Allows the ability to remove an existing predecessor ticket on a service ticket. | +| Delete | `NONE` | Disallows the ability to remove an existing predecessor ticket on a service ticket. | +| Inquire | `ALL` | Allows the ability to view existing predecessor ticket info on a service ticket. | +| Inquire | `NONE` | Disallows the ability to view existing predecessor ticket info on a service ticket. | + +### Service Ticket - Finance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to edit information on the Finance Tab of Service Tickets. | +| Edit | `NONE` | Retricts the ability to edit information on the Finance Tab of Service Tickets. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows access to the Finance Tab of Service Tickets. | +| Inquire | `NONE` | Retricts access to the Finance Tab of Service Tickets. | + +### Service Tickets + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create Service Tickets. | +| Add | `MY` | Allows the ability to create Service Tickets. | +| Add | `NONE` | Retricts the ability to create Service Tickets. | +| Edit | `ALL` | Allows the ability to edit existing Service Tickets. Allows the ability to follow Service Tickets. | +| Edit | `MY` | Allows the ability to edit only existing Service Tickets that belong to the member. A ticket belongs to me if I created it, assigned it to someone, or I am assigned to it. Allows the ability to follow Service Tickets I am a resource on. | +| Edit | `NONE` | Retricts the ability to edit existing Service Tickets. | +| Delete | `ALL` | Allows the ability to delete existing Service Tickets. | +| Delete | `MY` | Allows the ability to delete only existing Service Tickets that belong to the member. A ticket belongs to me if I created it, assigned it to someone, or I am assigned to it. | +| Delete | `NONE` | Retricts the ability to delete existing Service Tickets. | +| Inquire | `ALL` | Allows the ability to review existing Service Tickets. | +| Inquire | `MY` | Allows the ability to review only existing Service Tickets that belong to the member. A ticket belongs to me if I created it, assigned it to someone, or I am assigned to it. | +| Inquire | `NONE` | Retricts the ability to review existing Service Tickets. | + +### SLA Dashboard + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to access the On Call and Duty Mgr pickers on the SLA Dashboard and Service Board. | +| Edit | `MY` | Restricts the ability to access the On Call and Duty Mgr pickers on the SLA Dashboard and Service Board. | +| Edit | `NONE` | Restricts the ability to access the On Call and Duty Mgr pickers on the SLA Dashboard and Service Board. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to select any member from the member filter on the SLA Dashboard. Also allows the ability to access the On Call and Duty Mgr pickers on the Service Board and SLA Dashboard | +| Inquire | `MY` | Restricts the ability to select other members from the member filter on the SLA Dashboard. | +| Inquire | `NONE` | Restricts access to the SLA Dashboard. | + +### Ticket Templates + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. | +| Add | `MY` | Same as ALL | +| Add | `NONE` | Restricts the ability to create Ticket Templates at the Company Level. This includes the Ticket Templates Tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. | +| Edit | `ALL` | Allows the ability to edit Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. | +| Edit | `MY` | Allows the ability to edit Ticket Templates only where the member has access to the service board, location and group associated with the template. This includes the Ticket Templates tab of a company or agreement. | +| Edit | `NONE` | Restricts the ability to edit Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. | +| Delete | `ALL` | Allows the ability to delete Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. | +| Delete | `MY` | Allows the ability to delete Ticket Templates only where the member has access to the service board, location and group associated with the template. This includes the Ticket Templates tab of a company or agreement. | +| Delete | `NONE` | Restricts the ability to delete Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. | +| Inquire | `ALL` | Allows the ability to review Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. | +| Inquire | `MY` | Allows the ability to review Ticket Templates only where the member has access to the service board, location and group associated with the template. This includes the Ticket Templates tab of a company or agreement. | +| Inquire | `NONE` | Restricts the ability to review Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. | + +## System + +### API Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the user to access the API Reporting Views when using an API Member. | +| Inquire | `NONE` | Restricts the ability to review the API Reporting Views when using an API Member. | + +### Chat with ConnectWise Manage Support + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows access to the Chat with Support button in the top navigation menu. | +| Inquire | `NONE` | Restricts access to the Chat with Support button in the top navigation menu. | + +### ConnectWise Manage Labs + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit level._ | +| Edit | `ALL` | Allows the ability to turn labs on and off | +| Edit | `NONE` | Restricts the ablility to turn labs on and off | +| Delete | — | _Not applicable. See Edit level._ | +| Inquire | — | _Not applicable. See Edit level._ | + +### Custom Menu Entry + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review custom menu entries in the modules they are assigned to. By default, when this option is selected, access to all Custom Menu Entries is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Restricts the ability to review existing Custom Menu Entries. | + +### Email Audit + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows access to the Email Audit icon under the Service Desk module and the Email Audit tab on the Company screen | +| Inquire | `NONE` | Restricts access to the Email Audit icon under the Service Desk module and the Email Audit tab on the Company screen. | + +### List View Export + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to export list view items to an Excel Spreadsheet. | +| Inquire | `NONE` | Restricts the ability to export list view items to an Excel Spreadsheet. | + +### Marketplace Sharing + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to upload items to the ConnectWise Marketplace. | +| Add | `NONE` | Restricts the ability to upload items to the ConnectWise Marketplace. | +| Edit | — | _Not applicable. See Add and Inquire levels._ | +| Delete | — | _Not applicable. See Add and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to import items from the ConnectWise Marketplace. | +| Inquire | `NONE` | Restricts the ability to import items from the ConnectWise Marketplace. | + +### Mass Maintenance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add new items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. | +| Add | `NONE` | Restricts the ability to add new items via the Mass Maintenance screens. | +| Edit | `ALL` | Allows the ability to edit existing items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. | +| Edit | `NONE` | Restricts the ability to edit existing items via the Mass Maintenance screens. | +| Delete | `ALL` | Allows the ability to delete existing items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. | +| Delete | `NONE` | Restricts the ability to delete existing items via the Mass Maintenance screens. | +| Inquire | `ALL` | Allows the ability to review existing items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Restricts the ability to review existing items via the Mass Maintenance screens. | + +### Member Maintenance + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new member profiles. | +| Add | `NONE` | Restricts the ability to create new member profiles. | +| Edit | `ALL` | Allows the ability to edit existing member profiles. | +| Edit | `NONE` | Restricts the ability to edit existing member profiles. | +| Delete | `ALL` | Allows the ability to delete existing member profiles. | +| Delete | `NONE` | Restricts the ability to deleteexisting member profiles. | +| Inquire | `ALL` | Allows the ability to review existing member profiles. | +| Inquire | `NONE` | Restricts the ability to review existing member profiles. | + +### My Account + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to edit the information on the MY Account Screen for all members. | +| Edit | `MY` | Allows the ability to edit the information on the MY Account Screen for only the member logged in. | +| Edit | `NONE` | Retricts the ability to edit the information on the MY Account Screen | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to review the information on the MY Account Screen for all members. | +| Inquire | `MY` | Allows the ability to review the information on the MY Account Screen for only the member logged in. | +| Inquire | `NONE` | Retricts the ability to review the information on the MY Account Screen | + +### My Company + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add new structure and group levels to the My Company Screen. | +| Add | `NONE` | Restricts the ability to add new structure and group levels to the My Company Screen. | +| Edit | `ALL` | Allows the ability to edit the information on the My Company Screen. | +| Edit | `NONE` | Restricts the ability to edit the information on the My Company Screen. | +| Delete | `ALL` | Allows the ability to delete structure and group levels from the My Company Screen. | +| Delete | `NONE` | Restricts the ability to delete structure and group levels from the My Company Screen | +| Inquire | `ALL` | Allows the ability to review the information on the My Company Screen. | +| Inquire | `NONE` | Restricts the ability to review the information on the My Company Screen. | + +### Report Writer + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows access to the report writer designer | +| Add | `NONE` | Restricts access to the report writer designer | +| Edit | — | _Not applicable. See Add and Inquire levels._ | +| Delete | — | _Not applicable. See Add and Inquire levels._ | +| Inquire | `ALL` | Allows access to view report writer reports and dashboards. | +| Inquire | `NONE` | Restricts access to view report writer reports and dashboards. | + +### Security Roles + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new security roles. | +| Add | `NONE` | Restricts the ability to create new security roles. | +| Edit | `ALL` | Allows the ability to edit the settings for existing security roles. | +| Edit | `NONE` | Restricts the ability to edit the settings for existing security roles. | +| Delete | `ALL` | Allows the ability to delete existing security roles. | +| Delete | `NONE` | Restricts the ability to delete existing security roles. | +| Inquire | `ALL` | Allows the ability to review the settings for existing security roles. | +| Inquire | `NONE` | Restricts the ability to review the settings for existing security roles. | + +### System Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the System Category of the reports module. By default, when this option is selected, access to all System reports is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the System Category of the reports module. | + +### Table Setup + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create new items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. | +| Add | `NONE` | Restricts the ability to create new items within each of the Setup Tables. | +| Edit | `ALL` | Allows the ability to edit existing items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. | +| Edit | `NONE` | Restricts the ability to edit existing items within each of the Setup Tables. | +| Delete | `ALL` | Allows the ability to delete existing items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. | +| Delete | `NONE` | Restricts the ability to delete existing items within each of the Setup Tables. | +| Inquire | `ALL` | Allows the ability to review existing items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Restricts the ability to review existing items within each of the Setup Tables. | + +### Today Links + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to view and browse to the links setup on the Today screen. | +| Inquire | `NONE` | Restricts the ability to view and browse to the links setup on the Today screen. | + +## Time and Expense + +### Expense Approvals + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to approve expense reports and reverse expense report approvals. | +| Edit | `NONE` | Restricts the ability to approve expense reports and reverse expense report approvals. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to view expense reports submitted for approval. | +| Inquire | `NONE` | Restricts the ability to view expense reports submitted for approval. | + +### Expense Report Entry + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to add expense records within the system. This includes expense records for other members while on their Expense Report. | +| Add | `MY` | Allows the ability to add expenses only for the member logged in. | +| Add | `NONE` | Retricts the ability to add expense records within the system. | +| Edit | `ALL` | Allows the ability to edit all existing expense records within the system. | +| Edit | `MY` | Allows the ability to edit only the expenses that particular member has entered within the system. | +| Edit | `NONE` | Retricts the ability to edit existing expenses within the system. | +| Delete | `ALL` | Allows the ability to delete all existing expense records within the system. This includes expense records for other members. | +| Delete | `MY` | Allows the ability to delete only the expenses that particular member has entered within the system. | +| Delete | `NONE` | Retricts the ability to delete existing expenses within the system. | +| Inquire | `ALL` | Allows the ability to review all existing expense records within the system. | +| Inquire | `MY` | Allows the ability to review only the expenses that particular member has entered within the system. | +| Inquire | `NONE` | Retricts the ability to review existing expenses within the system. | + +### Reports + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Inquire level._ | +| Edit | — | _Not applicable. See Inquire level._ | +| Delete | — | _Not applicable. See Inquire level._ | +| Inquire | `ALL` | Allows the ability to review reports located in the Time Entry Category of the reports module. By default, when this option is selected, access to all Time Entry reports is allowed, but access can be customized using the Customize Option for this module. | +| Inquire | `NONE` | Retricts the ability to review reports located in the Time Entry Category of the reports module. | + +### Stopwatch + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to use the Stopwatch on both the All Tickets tab and in the Ticket screen. | +| Add | `NONE` | Restricts the ability to use the Stopwatch on the All Tickets tab. | +| Edit | — | _Not applicable. See Add and Inquire levels._ | +| Delete | — | _Not applicable. See Add and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to view the Stopwatch on the All Tickets tab, and grants the ability to use the Stopwatch in the Ticket screen. | +| Inquire | `NONE` | Restricts the ability to view the Stopwatch in the Ticket screen or in the All Tickets tab. | + +### Time Approval + +| Verb | Level | Description | +|------|-------|-------------| +| Add | — | _Not applicable. See Edit and Inquire levels._ | +| Edit | `ALL` | Allows the ability to approve/reject Time Sheet Approvals for all members. | +| Edit | `MY` | Allows the ability to approve/reject Time Sheet Approvals for only the users the member logged in is the approver for. | +| Edit | `NONE` | Retricts the ability to approve/reject Time Sheet Approvals. | +| Delete | — | _Not applicable. See Edit and Inquire levels._ | +| Inquire | `ALL` | Allows the ability to review Time Sheet Approvals for all members. | +| Inquire | `MY` | Allows the ability to review Time Sheet Approvals for only the users the member logged in is the approver for. | +| Inquire | `NONE` | Retricts the ability to review Time Sheet Approvals. | + +### Time Entry + +| Verb | Level | Description | +|------|-------|-------------| +| Add | `ALL` | Allows the ability to create time entries for all members. | +| Add | `MY` | Allows the ability tocreate time entries for only the member logged in. | +| Add | `NONE` | Retricts the ability to create time entries. | +| Edit | `ALL` | Allows the ability to edit existing time entries for all members. | +| Edit | `MY` | Allows the ability to edit existing time entries for only the member logged in. | +| Edit | `NONE` | Retricts the ability to edit existing time entries. | +| Delete | `ALL` | Allows the ability to delete existing time entries for all members. | +| Delete | `MY` | Allows the ability to delete existing time entries for only the member logged in. | +| Delete | `NONE` | Retricts the ability to delete existing time entries. | +| Inquire | `ALL` | Allows the ability to review existing time entries for all members. | +| Inquire | `MY` | Allows the ability to view existing time entries for only the member logged in. This includes the time tab and audit trail. Time entry notes will still appear in the notes pod and in the audit trail for all members. | +| Inquire | `NONE` | Retricts the ability to review existing time entries. | + diff --git a/docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml b/docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml new file mode 100644 index 00000000..320f75ee --- /dev/null +++ b/docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml @@ -0,0 +1,1913 @@ +# ConnectWise API Member Security Roles — reference matrix. +# Generated from the source XLSX; do not edit by hand. +# Re-run generate_role_docs.py after updating the XLSX. + +metadata: + source_file: Security_Roles_Matrix_11132017.xlsx + generated_on: '2026-04-16' + 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: + - ALL + - MY + - MINE + - NONE +modules: + Companies: + actions: + Company Maintenance: + add: + levels: + ALL: Allows the ability to create companies within the system. + NONE: Retricts the ability to create companies within the system. + edit: + levels: + ALL: Allows the ability to edit existing companies within the system. + NONE: Retricts the ability to edit existing companies within the system. + delete: + levels: + ALL: Allows the ability to delete existing companies within the system. + NONE: Retricts the ability to delete existing companies within the system. + inquire: + levels: + ALL: Allows the ability to review existing companies within the system. + NONE: Retricts the ability to review existing companies within the system. + Company/Contact Group Maintenance: + add: + levels: + ALL: |- + Allows the ability to create/add Group information on the Groups Tab of a company. + MY: Not Applicable. + NONE: |- + Retricts the ability to create/add Group information on the Groups Tab of a company. + edit: + levels: + ALL: |- + Allows the ability to edit existing Group information on the Groups Tab of a company. + MY: Not Applicable. + NONE: |- + Retricts the ability to edit existing Group information on the Groups Tab of a company. + delete: + levels: + ALL: |- + Allows the ability to delete existing Group information on the Groups Tab of a company. + MY: Not Applicable. + NONE: |- + Retricts the ability to delete existing Group information on the Groups Tab of a company. + inquire: + levels: + ALL: Allows the ability to review Group information on the Groups Tab of a company. + MY: Not Applicable. + NONE: Retricts the ability to review Group information on the Groups Tab of a company. + Configuration - Display Passwords: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + When reviewing configurations at the company level, Custom Configuration Questions that are labeled as "Password" in the Configuratoin Type Setup Table will be visible. + NONE: |- + When reviewing configurations at the company level, Custom Configuration Questions that are labeled as "Password" in the Configuration Type Setup Table will be encrypted. + Configurations: + add: + levels: + ALL: Allows the ability to create configurations within the system. + NONE: Retricts the ability to create configurations within the system. + edit: + levels: + ALL: Allows the ability to edit existing configurations within the system. + NONE: Retricts the ability to edit existing configurations within the system. + delete: + levels: + ALL: Allows the ability to delete existing configurations within the system. + NONE: Retricts the ability to delete existing configurations within the system. + inquire: + levels: + ALL: Allows the ability to review existing configurations within the system. + NONE: Retricts the ability to review existing configurations within the system. + Contacts: + add: + levels: + ALL: Allows the ability to create contacts within the system. + NONE: Retricts the ability to create contacts within the system. + edit: + levels: + ALL: Allows the ability to edit existing contacts within the system. + NONE: Retricts the ability to edit existing contacts within the system. + delete: + levels: + ALL: Allows the ability to delete existing contacts within the system. + NONE: Retricts the ability to delete existing contacts within the system. + inquire: + levels: + ALL: Allows the ability to review existing contacts within the system. + NONE: Retricts the ability to review existing contacts within the system. + CRM/Sales Activities: + add: + levels: + ALL: Allows the ability to add new activities. + MY: Allows the ability to add new activities (same as ALL). + NONE: Restricts the ability to add new activities. + edit: + levels: + ALL: Allows the ability to edit all existing activities within the system. + MY: |- + Allows the ability to edit only the activities that belong to a particular member. An activity belongs to me if I created it, if I assigned someone to it, or if I am assigned to it. + NONE: Retricts the ability to edit existing activities within the system. + delete: + levels: + ALL: Allows the ability to delete all existing activities within the system. + MY: |- + Allows the ability to delete activities that belong to a particular member. An activity belongs to me if I created it, if I assigned someone to it, or if I am assigned to it. + NONE: Retricts the ability to delete existing activities within the system. + inquire: + levels: + ALL: Allows the ability to review all existing activities within the system. + MY: |- + Allows the ability to review only the activities that belong to a particular member. An activity belongs to me if I created it, if I assigned someone to it, or if I am assigned to it. + NONE: |- + Retricts the ability to review existing activities within the system. NOTE: If set to "None" the My Activities Screen will no longer be visible. + Lead Import: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + note: |- + Not sure why this has a My / All option. It only controls whether or not I can see the Import Contacts menu icon. + Manage Documents: + add: + levels: + ALL: Allows the ability to add documents within the system. + MY: |- + Allows the ability to add documents that particular member uploaded within the system. + NONE: |- + Retricts the ability to add documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. + edit: + levels: + ALL: Allows the ability to edit all existing documents within the system. + MY: |- + Allows the ability to edit only the documents that particular member uploaded within the system. + NONE: |- + Retricts the ability to edit existing documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. + delete: + levels: + ALL: Allows the ability to delete all existing documents within the system. + MY: |- + Allows the ability to delete only the documents that particular member uploaded within the system. + NONE: |- + Retricts the ability to delete existing documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. + inquire: + levels: + ALL: Allows the ability to review all existing documents within the system. + MY: |- + Allows the ability to review only the documents that particular member uploaded within the system. + NONE: |- + Retricts the ability to review existing documents within the system. NOTE: This setting applies to the Documents Tab of Agreements, Companies, Contacts, Configurations, Expenses, Opportunities, Projects, and Service Tickets. + Management: + add: + levels: + ALL: Allows the ability to add information on the Management Tab of a company. + NONE: Retricts the ability to add information on the Management Tab of a company. + edit: + levels: + ALL: |- + Allows the ability to edit existing information on the Management Tab of a company. + NONE: |- + Retricts the ability to edit existing information on the Management Tab of a company. + delete: + levels: + ALL: |- + Allows the ability to delete existing information on the Management Tab of a company. + NONE: |- + Retricts the ability to delete existing information on the Management Tab of a company. + inquire: + levels: + ALL: |- + Allows the ability to review existing information on the Management Tab of a company. + NONE: |- + Retricts the ability to review existing information on the Management Tab of a company. + Notes: + add: + levels: + ALL: Allows the ability to add information on the Notes Tab of a company or contact. + NONE: |- + Retricts the ability to add information on the Notes Tab of a company or contact. + edit: + levels: + ALL: |- + Allows the ability to edit existing information on the Notes Tab of a company or contact. + NONE: |- + Retricts the ability to edit existing information on the Notes Tab of a company or contact. + delete: + levels: + ALL: |- + Allows the ability to delete existing information on the Notes Tab of a company or contact. + NONE: |- + Retricts the ability to delete existing information on the Notes Tab of a company or contact. + inquire: + levels: + ALL: |- + Allows the ability to review information on the Notes Tab of a company or contact. + NONE: |- + Retricts the ability to review information on the Notes Tab of a company or contact. + Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Companies Category of the reports module. By default, when this option is selected, access to all company and contact reports is allows, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Companies and Contacts Category of the reports module. + Surveys: + add: + levels: + ALL: |- + Allows the ability to add information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. + NONE: |- + Retricts the ability to add information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. + edit: + levels: + ALL: |- + Allows the ability to edit information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. + NONE: |- + Retricts the ability to edit information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. + delete: + levels: + ALL: |- + Allows the ability to delete information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. + NONE: |- + Retricts the ability to delete information on the Surveys Tab of a company or contact. This applies only to CRM Surveys. + inquire: + levels: + ALL: |- + Allows the ability to review information on the Surveys Tab of a company or contact. This applies to both CRM and Service Surveys. + NONE: |- + Retricts the ability to review information on the Surveys Tab of a company or contact. This applies to both CRM and Service Surveys. + Team Members: + add: + levels: + ALL: |- + Allows the ability to add Team Role information on the Team Tab of a company or contact. + NONE: |- + Retricts the ability to add Team Role information on the Team Tab of a company or contact. + edit: + levels: + ALL: |- + Allows the ability to edit existing Team Role information on the Team Tab of a company or contact. + NONE: |- + Retricts the ability to edit existing Team Role information on the Team Tab of a company or contact. + delete: + levels: + ALL: |- + Allows the ability to delete existing Team Role information on the Team Tab of a company or contact. + NONE: |- + Retricts the ability to delete existing Team Role information on the Team Tab of a company or contact. + inquire: + levels: + ALL: |- + Allows the ability to review existing Team Role information on the Team Tab of a company or contact. + NONE: |- + Retricts the ability to review existing Team Role information on the Team Tab of a company or contact. + Tracks: + add: + levels: + ALL: Allows the ability to add Track Items to the Tracks Tab of a company or contact. + NONE: |- + Retricts the ability to add Track Items to the Tracks Tab of a company or contact. + edit: + levels: + ALL: |- + Allows the ability to edit existing Track Items to the Tracks Tab of a company or contact. + NONE: |- + Retricts the ability to edit existing Track Items to the Tracks Tab of a company or contact. + delete: + levels: + ALL: |- + Allows the ability to delete existing Track Items to the Tracks Tab of a company or contact. + NONE: |- + Retricts the ability to delete existing Track Items to the Tracks Tab of a company or contact. + inquire: + levels: + ALL: |- + Allows the ability to review Track Items to the Tracks Tab of a company or contact. + NONE: |- + Retricts the ability to review Track Items to the Tracks Tab of a company or contact. + UserCentric: + add: + note: Not Applicable. See Inquire Level. + edit: + note: Not Applicable. See Inquire Level. + delete: + note: Not Applicable. See Inquire Level. + inquire: + levels: + ALL: Allows access to the UserCentric Icon under the Companies Module. + NONE: Retricts access to the UserCentric Icon under the Companies Module. + Finance: + actions: + Accounting Interface: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + levels: + ALL: |- + Allows the ability to delete GL Batches from the Open Batches tab and to Remove Records on the Unposted Invoices, Unposted Expenses and Unposted Procurement tabs. + NONE: |- + Restricts the ability to delete GL Batches from the Open Batches tab and to Remove Records on the Unposted Invoices, Unposted Expenses and Unposted Procurement tabs. + inquire: + levels: + ALL: Allows access to the Accounting Interface Screen. + NONE: Retricts access to the Accounting Interface Screen. + Agreement Invoicing: + add: + levels: + ALL: Allows the ability to create agreement invoices within the system. + NONE: Retricts the ability to create agreement invoices within the system. + edit: + levels: + ALL: Allows the ability to edit existing agreement invoices within the system. + NONE: Retricts the ability to edit existing agreement invoices within the system. + delete: + levels: + ALL: Allows the ability to delete existing agreement invoices within the system. + NONE: Retricts the ability to delete existing agreement invoices within the system. + inquire: + levels: + ALL: Allows the ability to review existing agreement invoices within the system. + NONE: Retricts the ability to review existing agreement invoices within the system. + Agreement Sales: + add: + note: Not applicable. See Inquire and Edit levels. + edit: + levels: + ALL: Allows the use of the actions on the Agreement Sales screen. + NONE: Restricts the ability to use the actions on the Agreement Sales screen. + delete: + note: Not applicable. See Inquire and Edit levels. + inquire: + levels: + ALL: Allows access to the Agreement Sales screen. + NONE: Restricts access to the Agreement Sales screen. + Agreements: + add: + levels: + ALL: Allows the ability to create agreements within the system. + NONE: Retricts the ability to create agreements within the system. + edit: + levels: + ALL: |- + Allows the ability to edit existing agreements within the system. This also includes all tabs on the agreement. + NONE: |- + Retricts the ability to edit existing agreements within the system. This also includes all tabs on the agreement. + delete: + levels: + ALL: Allows the ability to delete existing agreements within the system. + NONE: Retricts the ability to delete existing agreements within the system. + inquire: + levels: + ALL: Allows the ability to review existing agreements within the system. + NONE: Retricts the ability to review existing agreements within the system. + Billing Rate Maintenance: + add: + levels: + ALL: Allows the ability to add custom work roles (agreements, companies). + NONE: Restricts the ability to add custom work roles (agreements, companies). + edit: + levels: + ALL: Allows the ability to edit custom work roles (agreements, companies). + NONE: Restricts the ability to edit custom work roles (agreements, companies). + delete: + levels: + ALL: Allows the ability to delete custom work roles (agreements, companies). + NONE: Restricts the ability to delete custom work roles (agreements, companies). + inquire: + levels: + ALL: Allows the ability to view custom work roles (agreements, companies). + NONE: Restricts the ability to view custom work roles (agreements, companies). + Billing view Time: + add: + note: Not applicable. See Edit and Inquire level. + edit: + levels: + ALL: Enables you to edit the Billing Options pod. + NONE: |- + Restricts the ability to view the Billing Options pod on a time or expense entry. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to view billing options section on a time or expense entry. + NONE: |- + Restricts the ability to view billing options section on a time or expense entry. + Company Finance: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to edit the Company Finance Screen information for companies within the system. + NONE: |- + Retricts the ability to edit the Company Finance Screen information for companies within the system. + delete: + note: Not Applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: |- + Allows the ability to review the Company Finance Screen for companies within the system. + NONE: |- + Retricts the ability to review the Company Finance Screen for companies within the system. + Expense Reimbursement: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows access to the Expense Reimbursement Screen. + NONE: Retricts access to the Expense Reimbursement Screen. + Financial Dashboard: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not Applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to review the Financial Dashboard. + NONE: Retricts the ability to review the Financial Dashboard. + Invoice Approval: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: Allows the ability to approve (route) any invoices within the system. + MY: |- + Allows the ability to approve (route) any invoices that are currently routed to the particular member. + NONE: Restricts the ability to approve (route) invoices within the system. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: |- + Restricts access to email invoices via the Invoice Batch Emailing screen and also approvals via the My Invoices Screen. + MY: Same as ALL. + Invoicing: + add: + levels: + ALL: Allows the ability to create invoices within the system. + MY: Allows the ability to create invoices within the system (same as ALL). + NONE: Retricts the ability to create invoices within the system. + edit: + levels: + ALL: Allows the ability to edit all existing invoices within the system. + MY: |- + Allows the ability to edit only the existing invoices that are routed to a particular member in the system. + NONE: Retricts the ability to edit existing invoices within the system. + delete: + levels: + ALL: Allows the ability to delete all existing invoices within the system. + MY: |- + Allows the ability to delete only the existing invoices that are routed to a particular member within the system. + NONE: Retricts the ability to delete existing invoices within the system. + inquire: + levels: + ALL: Allows the ability to view all existing invoices in the system. + MY: |- + Allows the ability to view only the existing invoices that are routed to a particular member within the system. + NONE: |- + Retricts the ability to review the Invoicing, Invoice Search, or Special Invoices screens. + Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Finance Category of the reports module. By default, when this option is selected, access to all company and contact reports is allows, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Finance Category of the reports module. + Marketing: + actions: + ConnectWise Campaign: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to view ConnectWise Campaign in the left nacigation. + NONE: Retricts the ability to view or interact with ConnectWise Campaign. + Marketing Groups: + add: + levels: + ALL: Allows the ability to create new Groups. + NONE: Restricts the ability to create new Groups. + edit: + levels: + ALL: Allows the ability to edit existing Groups. + NONE: Restricts the ability to edit existing Groups. + delete: + levels: + ALL: Allows the ability to delete existing Groups. + NONE: Restricts the ability to delete existing Groups. + inquire: + levels: + ALL: Allows the ability to access the Marketing Groups screen. + NONE: Restricts the ability to access the Marketing Groups screen. + Marketing Management: + add: + levels: + ALL: |- + Allows the ability to create marketing items located within the Marketing Module. + MY: |- + Allows the ability to create marketing items located within the Marketing Module only for those items that member is the Owner. + NONE: |- + Retricts the ability to create marketing items located within the Marketing Module. + edit: + levels: + ALL: Allows the ability to edit marketing items located within the Marketing Module. + MY: |- + Allows the ability to edit marketing items located within the Marketing Module only for those items that member is the Owner. + NONE: |- + Retricts the ability to edit marketing items located within the Marketing Module. + delete: + levels: + ALL: |- + Allows the ability to delete marketing items located within the Marketing Module. + MY: |- + Allows the ability to delete marketing items located within the Marketing Module only for those items that member is the Owner. + NONE: |- + Retricts the ability to delete marketing items located within the Marketing Module. + inquire: + levels: + ALL: |- + Allows the ability to review marketing items located within the Marketing Module. + MY: |- + Allows the ability to review marketing items located within the Marketing Module only for those items that member is the Owner. + NONE: |- + Retricts the ability to review marketing items located within the Marketing Module. + Marketing Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Marketing Category of the reports module. By default, when this option is selected, access to all marketing reports is allowed, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Marketing Category of the reports module. + Procurement: + actions: + Inventory Adjustments: + add: + levels: + ALL: Allows the ability to create new Inventory Adjustments. + NONE: Retricts the ability to create new Inventory Adjustments. + edit: + levels: + ALL: Allows the ability to edit existing open Inventory Adjustment item information. + NONE: Retricts the ability to edit existing Inventory Adjustment item information. + delete: + levels: + ALL: Allows the ability to delete existing open Inventory Adjustment items. + NONE: Retricts the ability to delete existing Inventory Adjustment items. + inquire: + levels: + ALL: Allows the ability to review existing Inventory Adjustment item information. + NONE: Retricts the ability to review existing Inventory Adjustment item information. + Inventory Transfers: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: Allows the ability to complete a Inventory Transfer. + NONE: Retricts the ability to complete a Inventory Transfer. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: Allows the ability to review existing Inventory Transfer information. + NONE: Retricts the ability to review existing Inventory Transfer information. + Product Catalog: + add: + levels: + ALL: Allows the ability to create new Products in the Product Catalog. + NONE: Restricts the ability to create new products in the Product Catalog. + edit: + levels: + ALL: Allows the ability to edit existing products in the Products Catalog. + NONE: Restricts the ability to edit existing products in the Products Catalog. + delete: + levels: + ALL: Allows the ability to delete existing Products in the Products Catalog. + NONE: Restricts the ability to delete existing Products in the Products Catalog. + inquire: + levels: + ALL: |- + Allows the ability to review existing Product information in the Product Catalog. + NONE: |- + Restricts the ability to review existing Product information in the Product Catalog. + Products: + add: + levels: + ALL: |- + Allows the ability to add Products to Opportunities, Sales Orders, and Service Tickets. + NONE: |- + Retricts the ability to add Products to Opportunities, Sales Orders, and Service Tickets. NOTE: This option also controls the ability to pick and ship products. + edit: + levels: + ALL: |- + Allows the ability to edit Products on Opportunities, Sales Orders, Service Tickets, and Invoices. + NONE: |- + Restricts the ability to edit Products on Opportunities, Sales Orders, Service Tickets, and Invoices. + delete: + levels: + ALL: |- + Allows the ability to delete products added to Opportunities, Sales Orders, Service Tickets, and Invoices. + NONE: |- + Restricts the ability to delete products added to Opportunities, Sales Orders, Service Tickets, and Invoices. + inquire: + levels: + ALL: |- + Allows the ability to review information for products added to Opportunities, Sales Orders, and Service Tickets. + NONE: |- + Retricts the ability to review information for products added to Opportunities, Sales Orders, and Service Tickets. + Purchase Orders: + add: + levels: + ALL: Allows the ability to create new POs. + MY: Allows the ability to create new POs. + NONE: Restricts the ability to create new POs. + edit: + levels: + ALL: Allows the ability to edit existing POs. + MY: |- + Allows the ability to edit existing PO's where the Member is listed in the Entered By field on the PO. + NONE: Restricts the ability to edit existing POs. + delete: + levels: + ALL: Allows the ability to delete existing POs. + MY: |- + Allows the ability to delete existing PO's where the Member is listed in the Entered By field on the PO. + NONE: Restricts the ability to delete existing POs. + inquire: + levels: + ALL: Allows the ability to view existing POs. + MY: |- + Allows the ability to delete existing PO's where the Member is listed in the Entered By field on the PO. + NONE: Restricts the ability to view existing POs. + Purchasing Approvals: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to use actionable options for items in the Purchasing Approvals Search Screen. + NONE: |- + Retricts the ability to use actionable options for items in the Purchasing Approvals Search Screen. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: |- + Allows the ability to review information for items in the Purchasing Approvals Search Screen. + NONE: |- + Retricts the ability to review information for items in the Purchasing Approvals Search Screen. + Purchasing Demand: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to use actionable options for items in the Purchasing Search Screen. + NONE: |- + Retricts the ability to use actionable options for items in the Purchasing Search Screen. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: |- + Allows the ability to review information for items in the Purchasing Search Screen. + NONE: |- + Retricts the ability to review information for items in the Purchasing Search Screen. + Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Procurement Category of the reports module. By default, when this option is selected, access to all procurement reports is allowed, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Procurement Category of the reports module. + RMA Entry: + add: + levels: + ALL: Allows the ability to create new RMA Tags. + NONE: Restricts the ability to create new RMA Tags. + edit: + levels: + ALL: Allows the ability to edit existing RMA Tags. + NONE: Restricts the ability to edit existing RMA Tags. + delete: + levels: + ALL: Allows the ability to delete existing RMA Tags. + NONE: Restricts the ability to delete existing RMA Tags. + inquire: + levels: + ALL: Allows the ability to view existing RMA Tags. + NONE: Restricts the ability to view existing RMA Tags. + RMA Processing: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to view vendor, shipping, and closing sections of RMA tags. + NONE: |- + Restricts the ability to view vendor, shipping, and closing sections of RMA tags. + Project: + actions: + Close Project Tickets: + add: + note: Not applicable. See Edit level. + edit: + levels: + ALL: Allows the ability to close and re-open Projects and Project Tickets. + NONE: Retricts the ability to close and re-open Projects and Project Tickets. + delete: + note: Not applicable. See Edit level. + inquire: + note: Not applicable. See Edit level. + Close Projects: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: Allows the ability to set existing Projects to a closed status. + NONE: Disallows the ability to set existing Projects to a closed status. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: Allows the ability to view Closed statuses on the Project tab of a Project. + NONE: Disallows the ability to view Closed statuses on the Project tab of a Project. + Project Contacts: + add: + levels: + ALL: |- + Allows the ability to add contact information to the Contacts Tab of existing projects. + MY: |- + The ability to add contact information to the Contacts Tab for projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to add contact information to the Contacts Tab of existing projects. + edit: + levels: + ALL: |- + Allows the ability to edit contact information to the Contacts Tab of existing projects. + MY: |- + The ability to edit contact information on the Contacts Tab for projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to edit contact information to the Contacts Tab of existing projects. + delete: + levels: + ALL: |- + Allows the ability to delete contact information to the Contacts Tab of existing projects. + MY: |- + The ability to delete contact information from the Contacts Tab for projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to delete contact information to the Contacts Tab of existing projects. + inquire: + levels: + ALL: |- + Allows the ability to review contact information to the Contacts Tab of existing projects. + MY: |- + The ability to review contact information on the Contacts Tab of projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to review contact information to the Contacts Tab of existing projects. + Project Finance: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to edit the Project Finance, Recap, and Invoice tabs of existing projects. + MY: |- + Access to the Finance, Recap, and Invoices tabs of the project or project ticket is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Restricts the ability to edit the Project Finance, Recap, and Invoice tabs of existing projects. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: |- + Allows the ability to review the Project Finance, Recap, and Invoice tabs of existing projects. Also allows access to the Project budget by Variance view in the Views tab. + MY: |- + The ability to review the Finance, Recap or Invoices Tabs of the project or project ticket is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Restricts the ability to review the Project Finance, Recap, and Invoice tabs of existing projects. Also restricts access to the Project budget by Variance view in the Views tab. NOTE: If you select None, the Project Boards icon will no longer be visible. In order to see the Project Board icon, the user needs Project Management Inquire set to All, AND Project Finance Inquire set to My at the very least. + Project Headers: + add: + levels: + ALL: Allows the ability to add new projects. + MY: |- + The ability to add new projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Restricts the ability to add new projects. + edit: + levels: + ALL: Allows the ability to edit the general tab of existing projects. + MY: |- + The ability to edit the general tab of existing projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Restricts the ability to edit the general tab of existing projects. + delete: + levels: + ALL: Allows the ability to delete existing projects. + MY: |- + The ability to delete existing projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Restricts the ability to delete existing projects. + inquire: + levels: + ALL: Allows the ability to view existing projects. + MY: |- + The ability to view existing projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Restricts the ability to view existing projects. + Project Management: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to view Project Boards. + MY: Not Applicable. + NONE: |- + Retricts the ability to view Project Boards. NOTE: The only available options are All or None. If you select None, the Project Boards icon will no longer be visible. In order to see the Project Board icon, the user needs Project Management Inquire set to All, AND Project Finance Inquire set to My at the very least. + Project Notes: + add: + levels: + ALL: Allows the ability to add information on the Notes Tab of a Project. + MY: |- + The ability to add information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to add information on the Notes Tab of a Project. + edit: + levels: + ALL: Allows the ability to edit information on the Notes Tab of a Project. + MY: |- + The ability to edit information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to edit information on the Notes Tab of a Project. + delete: + levels: + ALL: Allows the ability to delete information on the Notes Tab of a Project. + MY: |- + The ability to delete information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to delete information on the Notes Tab of a Project. + inquire: + levels: + ALL: Allows the ability to review information on the Notes Tab of a Project. + MY: |- + The ability to view information on the Notes Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to review information on the Notes Tab of a Project. + Project Phase: + add: + levels: + ALL: Allows the ability to add a new Phase on the Workplan Tab of a Project. + MY: |- + The ability to add a new Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to add a new Phase on the Workplan Tab of a Project. + edit: + levels: + ALL: Allows the ability to edit an existing Phase on the Workplan Tab of a Project. + MY: |- + The ability to edit a Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to edit an existing Phase on the Workplan Tab of a Project. + delete: + levels: + ALL: Allows the ability to delete an existing Phase on the Workplan Tab of a Project. + MY: |- + The ability to delete a Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to delete an existing Phase on the Workplan Tab of a Project. + inquire: + levels: + ALL: Allows the ability to review an existing Phase on the Workplan Tab of a Project. + MY: |- + The ability to view a Phase on the Workplan Tab to a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to review an existing Phase on the Workplan Tab of a Project. + Project Product: + add: + levels: + ALL: Allows the ability to add product items to the ProductsTab of existing projects. + MY: |- + The ability to add product items to the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to add product items to the ProductsTab of existing projects. + edit: + levels: + ALL: |- + Allows the ability to edit product items to the ProductsTab of existing projects. + MY: |- + The ability to edit product items on the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to edit product items to the ProductsTab of existing projects. + delete: + levels: + ALL: |- + Allows the ability to delete product items to the ProductsTab of existing projects. + MY: |- + The ability to delete product items from the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to delete product items to the ProductsTab of existing projects. + inquire: + levels: + ALL: |- + Allows the ability to review product items to the ProductsTab of existing projects. + MY: |- + The ability to view product items on the ProductsTab of projects is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to review product items to the ProductsTab of existing projects. + Project Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Project Category of the reports module. By default, when this option is selected, access to all marketing reports is allowed, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Project Category of the reports module. + Project Scheduling: + add: + levels: + ALL: |- + Allows the ability to add information on the Schedule Tab of an all existing Projects. + MY: |- + The ability to add information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to add information on the Schedule Tab of a Project. + edit: + levels: + ALL: |- + Allows the ability to edit the information stored on the Schedule Tab of an all existing Projects. + MY: |- + The ability to edit information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to edit the information stored on the Schedule Tab of a Project. + delete: + levels: + ALL: |- + Allows the ability to delete the information stored on the Schedule Tab of an all existing Projects. + MY: |- + The ability to delete information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to delete the information stored on the Schedule Tab of a Project. + inquire: + levels: + ALL: |- + Allows the ability to review the information stored on the Schedule Tab of an all existing Projects. + MY: |- + The ability to view information on the Schedule Tab of a project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the review the information stored on the Schedule Tab of a Project. + Project Teams: + add: + levels: + ALL: |- + Allows the ability to add members on the Project Team Tab of an all existing Projects. Also allows the ability to convert Project tickets to Service Tickets. + MY: |- + The ability to add members on the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to add members on the Project Team Tab of a Project. + edit: + levels: + ALL: Allows the ability to edit members on the Project Team Tab of a Project. + MY: |- + The ability to edit members on the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to edit members on the Project Team Tab of a Project. + delete: + levels: + ALL: Allows the ability to delete members on the Project Team Tab of a Project. + MY: |- + The ability to delete members from the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to delete members on the Project Team Tab of a Project. + inquire: + levels: + ALL: |- + Allows the ability to review member details on the Project Team Tab of a Project. Also allows the ability to convert Project tickets to Service Tickets. + MY: |- + The ability to view members on the Team Tab of a Project is determined by the settings of the project role the member is assigned to on the specific project. + NONE: |- + Retricts the ability to review member details on the Project Team Tab of a Project. + Project Templates: + add: + levels: + ALL: Allows the ability to create Project Templates. + NONE: Retricts the ability to create Project Templates. + edit: + levels: + ALL: Allows the ability to edit Project Templates. + NONE: Retricts the ability to edit Project Templates. + delete: + levels: + ALL: Allows the ability to delete Project Templates. + NONE: Retricts the ability to delete Project Templates. + inquire: + levels: + ALL: Allows the ability to review Project Templates. + NONE: Retricts the ability to review Project Templates. + Project Ticket - Dependencies: + add: + levels: + ALL: Allows the ability to append predecessor tickets to a project ticket. + NONE: Disallows the ability to append predecessor tickets to a project ticket. + edit: + levels: + ALL: Allows the ability to edit the existing predecessor ticket on a project ticket. + NONE: |- + Disallows the ability to edit the existing predecessor ticket on a project ticket. + delete: + levels: + ALL: Allows the ability to remove an existing predecessor ticket on a project ticket. + NONE: |- + Disallows the ability to remove an existing predecessor ticket on a project ticket. + inquire: + levels: + ALL: Allows the ability to view existing predecessor ticket info on a project ticket. + NONE: |- + Disallows the ability to view existing predecessor ticket info on a project ticket. + Project Ticket Tasks: + add: + levels: + ALL: Allows the ability to add tasks to Project Tickets. + MY: |- + The ability to add tasks to Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to add tasks to Project Tickets. + edit: + levels: + ALL: Allows the ability to edit tasks on Project Tickets. + MY: |- + The ability to edit tasks on Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to edit tasks on Project Tickets. + delete: + levels: + ALL: Allows the ability to delete tasks on Project Tickets. + MY: |- + The ability to delete tasks from Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to delete tasks on Project Tickets. + inquire: + levels: + ALL: Allows the ability to review tasks on Project Tickets. + MY: |- + The ability to review tasks on Project Tickets(not Projects) is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to review tasks on Project Tickets. + Project Tickets: + add: + levels: + ALL: Allows the ability to create Project Tickets. + MY: |- + The ability to create project tickets is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to create Project Tickets. + edit: + levels: + ALL: Allows the ability to edit existing Project Tickets. + MY: |- + The ability to edit Project Tickets is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to edit existing Project Ticket information. + delete: + levels: + ALL: Allows the ability to delete existing Project Tickets. + MY: |- + The ability to delete existing Project Tickets is determined by the settings of the project role the member is assigned to on the specific project. + NONE: Retricts the ability to review existing delete existing Project Tickets. + inquire: + levels: + ALL: |- + Allows the ability to review existing Project Ticket information. Allows the ability to view Project Tickets and Issues on the My List screen. + MY: |- + The ability to review Project Tickets is determned by the settings of the project role the member is assigned to on the specific project. Allows the ability to view Project Tickets and Issues on the My List screen. + NONE: |- + Retricts the ability to review existing Project Ticket information. NOTE: Controls the ability to view the Project and Issues tabs on the My List screen + Sales: + actions: + Closed Opportunity: + add: + note: Not applicable. See Edit level. + edit: + levels: + ALL: Allows the ability to close and re-open opportunities. + NONE: Restricts the ability to close and re-open opportunities. + delete: + note: Not applicable. See Edit level. + inquire: + note: Not applicable. See Edit level. + Opportunity: + add: + levels: + ALL: Allows the ability to create Opportunities. + MY: Allows the ability to create Opportunities. + NONE: Retricts the ability to create Opportunities. + edit: + levels: + ALL: Allows the ability to edit existing Opportunities. + MY: |- + Allows the ability to edit only those Opportunities for which the member is part of the team or is the teammate of a member of the team that has the Allow Access checkbox checked. + NONE: Retricts the ability to edit existing Opportunities. + delete: + levels: + ALL: Allows the ability to delete existing Opportunities. + MY: |- + Allows the ability to edit only those Opportunities for which the member is part of the team or is the teammate of a member on the team that has the Allow Access checkbox checked. + NONE: Retricts the ability to delete existing Opportunities. + inquire: + levels: + ALL: Allows the ability to review existing Opportunities. + MY: |- + Allows the ability to edit only those Opportunities for which the member is part of the team or is the teammate of a member of the team that has the Allow Access checkbox checked. + NONE: Retricts the ability to review existing Opportunities. + Opportunity Finance: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to review the Finance Tab of Opportunities. + NONE: Retricts the ability to review the Finance Tab of Opportunities. + Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Sales Category of the reports module. By default, when this option is selected, access to all marketing reports is allowed, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Sales Category of the reports module. + Sales Dashboard: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to review the Sales Overview screen. + NONE: Retricts the ability to review the Sales Overview screen. + Sales Funnel: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to review the Sales Funnel screen. + NONE: Retricts the ability to review the Sales Funnel screen. + Sales Order Finance: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to review the Finance Tab of Opportunities. + NONE: |- + Restricts the ability to review the Finance Tab of Opportunities. NOTE: The ability to edit the Finance tab of a Sales Order is based on the security persmissions for this role in the Sales Order field. + Sales Orders: + add: + levels: + ALL: Allows the ability to create Sales Orders. + NONE: Retricts the ability to create Sales Orders. + edit: + levels: + ALL: Allows the ability to edit existing Sales Orders. + NONE: Retricts the ability to edit existing Sales Orders. + delete: + levels: + ALL: Allows the ability to delete existing Sales Orders. + NONE: Retricts the ability to delete existing Sales Orders. + inquire: + levels: + ALL: Allows the ability to review existing Sales Orders. + NONE: Retricts the ability to review existing Sales Orders. + Service Desk: + actions: + Agile Board: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to view and use the Agile board. + NONE: Retricts the ability to view and use the Agile board. + ChatAssist: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows access to the ChatAssist Icon under the Service Desk Module. + NONE: Retricts access to the ChatAssist Icon under the Service Desk Module. + Close Service Tickets: + add: + levels: + ALL: |- + Allows users to set a new ticket to a closed status before saving it for the first time. + NONE: |- + Restricts the ability to set a new ticket to a closed status before saving it for the first time. + edit: + levels: + ALL: Allows the ability to close and re-open service tickets. + NONE: Restricts the ability to close and re-open service tickets. + delete: + note: Not Applicable. See Add, Edit, and Inquire levels. + inquire: + levels: + ALL: Allows the ability to view closed statuses in Service Ticket Status dropdown + NONE: Restricts the ability to view closed statuses in Service Ticket Status dropdown. + CloudConsole: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows access to the CloudConsole Icon under the Service Desk Module. + NONE: Retricts access to the CloudConsole Icon under the Service Desk Module. + ConnectWise Control: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Enables the ScreenConnect Button on tickets allowing the ability to create a ScreenConnect session. + NONE: |- + Disables the ScreenConnect Button on tickets disallowing the ability to create a ScreenConnect session. + ConnectWise Manage Network: + add: + note: Not applicable. See Add and Inquire levels. + edit: + note: Not applicable. See Add and Inquire levels. + delete: + levels: + ALL: Allows the ability to remove holds from the ConnectWise Manage Network. + NONE: Restricts the ability to remove holds from the ConnectWise Manage Network. + inquire: + levels: + ALL: Allows access to the ConnectWise Manage Network. + NONE: Retricts access to the ConnectWise Manage Network. + Knowledge Base Approver: + add: + note: Not applicable. See Edit, Delete, and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to approve and reject Knowledge Base articles that are pending approval. + NONE: |- + Restricts the ability to approve and reject Knowledge Base articles that are pending approval. + delete: + levels: + ALL: |- + Allows the ability to delete Knowledge Base articles in any status created by any member. + NONE: Restricts the ability to delete any Knowledge Base articles. + inquire: + levels: + ALL: |- + Allows the ability to review Knowledge Base articles in any status. NONEL Restricts the ability to review any Knowledge Base articles. + Knowledge Base Creator: + add: + levels: + ALL: Allows the ability to add draft Knowledge Base articles and submit for approval. + MY: Allows the ability to add draft Knowledge Base articles and submit for approval. + NONE: |- + Restricts the ability to add draft Knowledge Base articles and submit for approval. + edit: + levels: + ALL: |- + Allows the ability to edit Knowledge Base articles in Draft, Rejected, and Revise status for any member. + MY: |- + Allows the ability to edit Knowledge Base articles in Draft, Rejected, and Revise status created by the logged in member. + NONE: Restricts the ability to edit Knowledge Base articles. + delete: + levels: + ALL: |- + Allows the ability to delete Knowledge Base articles in Draft, Rejected, and Revise status created by any member. + MY: |- + Allows the ability to delete Knowledge Base articles in Draft, Rejected, and Revise status created by the logged in member. + NONE: Restricts the ability to delete Knowledge Base articles. + inquire: + levels: + ALL: Allows the ability to review Knowledge Base articles created by any member. + MY: |- + Allows the ability for a member to review Knowledge Base articles created by the logged in member. + NONE: Restricts the ability to review any Knowledge Base articles. + Launch Remote Access: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to launch Automate or Control access from any ticket. + MY: |- + Allows the ability to launch Automate or Control access from any ticket the member is a resource on. + NONE: |- + Restricts the ability to launch Automate or Control access. Hides the Launch column in the Configuration pod. + Merge Tickets: + add: + levels: + ALL: Allows the ability to merge tickets. + NONE: Restricts the ability to merge tickets + edit: + levels: + ALL: Allows for Open merged tickets to be set to a Closed status. + NONE: Restricts the ability to merge tickets + delete: + note: Not applicable. See Add, Edit, and Inquire levels. + inquire: + levels: + ALL: Allows the ability to view the relationship of merged tickets. + NONE: Restricts the ability to view the relationship of merged tickets. + Print Service Signoff: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to print the Signoff form for any service ticket. + MY: |- + Allows the ability to print the Signoff form for tickets you are a resource on, tickets you have assigned another resource on, and tickets created by you. + NONE: Restricts the ability to print the Signoff form for all tickets. + Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Service Desk Category of the reports module. By default, when this option is selected, access to all Service reports is allowed, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Service Desk Category of the reports module. + Resource Scheduling: + add: + levels: + ALL: Allows the ability to add resources on Service Tickets and Activities. + MY: |- + Allows the ability for a member to add themselves as a resource on a Service Ticket or Activity. + NONE: |- + Retricts the ability to add resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. + edit: + levels: + ALL: Allows the ability to edit resources on Service Tickets and Activities. + MY: |- + Allows the ability for a member to edit themselves as a resource on a Service Ticket or Activity. + NONE: |- + Retricts the ability to edit resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. + delete: + levels: + ALL: Allows the ability to delete resources on Service Tickets and Activities. + MY: |- + Allows the ability for a member to delete themselves as a resource on a Service Ticket or Activity. + NONE: |- + Retricts the ability to delete resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. + inquire: + levels: + ALL: Allows the ability to review resources on Service Tickets and Activities. + MY: |- + Allows the ability for a member to review only their resource records on a Service Ticket or Activity. + NONE: |- + Retricts the ability to review resources on Service Tickets and Activities. NOTE: This includes the My Calendar and Dispatch Portal Screens. + Service Dashboard: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows access to the Service Dashboard under the Service Desk Module. + NONE: Retricts access to the Service Dashboard under the Service Desk Module. + Service Ticket - Dependencies: + add: + levels: + ALL: Allows the ability to append predecessor tickets to a service ticket. + NONE: Disallows the ability to append predecessor tickets to a service ticket. + edit: + levels: + ALL: Allows the ability to edit the existing predecessor ticket on a service ticket. + NONE: |- + Disallows the ability to edit the existing predecessor ticket on a service ticket. + delete: + levels: + ALL: Allows the ability to remove an existing predecessor ticket on a service ticket. + NONE: |- + Disallows the ability to remove an existing predecessor ticket on a service ticket. + inquire: + levels: + ALL: Allows the ability to view existing predecessor ticket info on a service ticket. + NONE: |- + Disallows the ability to view existing predecessor ticket info on a service ticket. + Service Ticket - Finance: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: Allows the ability to edit information on the Finance Tab of Service Tickets. + NONE: Retricts the ability to edit information on the Finance Tab of Service Tickets. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: Allows access to the Finance Tab of Service Tickets. + NONE: Retricts access to the Finance Tab of Service Tickets. + Service Tickets: + add: + levels: + ALL: Allows the ability to create Service Tickets. + MY: Allows the ability to create Service Tickets. + NONE: Retricts the ability to create Service Tickets. + edit: + levels: + ALL: |- + Allows the ability to edit existing Service Tickets. Allows the ability to follow Service Tickets. + MY: |- + Allows the ability to edit only existing Service Tickets that belong to the member. A ticket belongs to me if I created it, assigned it to someone, or I am assigned to it. Allows the ability to follow Service Tickets I am a resource on. + NONE: Retricts the ability to edit existing Service Tickets. + delete: + levels: + ALL: Allows the ability to delete existing Service Tickets. + MY: |- + Allows the ability to delete only existing Service Tickets that belong to the member. A ticket belongs to me if I created it, assigned it to someone, or I am assigned to it. + NONE: Retricts the ability to delete existing Service Tickets. + inquire: + levels: + ALL: Allows the ability to review existing Service Tickets. + MY: |- + Allows the ability to review only existing Service Tickets that belong to the member. A ticket belongs to me if I created it, assigned it to someone, or I am assigned to it. + NONE: Retricts the ability to review existing Service Tickets. + SLA Dashboard: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to access the On Call and Duty Mgr pickers on the SLA Dashboard and Service Board. + MY: |- + Restricts the ability to access the On Call and Duty Mgr pickers on the SLA Dashboard and Service Board. + NONE: |- + Restricts the ability to access the On Call and Duty Mgr pickers on the SLA Dashboard and Service Board. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: |- + Allows the ability to select any member from the member filter on the SLA Dashboard. Also allows the ability to access the On Call and Duty Mgr pickers on the Service Board and SLA Dashboard + MY: |- + Restricts the ability to select other members from the member filter on the SLA Dashboard. + NONE: Restricts access to the SLA Dashboard. + Ticket Templates: + add: + levels: + ALL: |- + Allows the ability to create Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. + MY: Same as ALL + NONE: |- + Restricts the ability to create Ticket Templates at the Company Level. This includes the Ticket Templates Tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. + edit: + levels: + ALL: |- + Allows the ability to edit Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. + MY: |- + Allows the ability to edit Ticket Templates only where the member has access to the service board, location and group associated with the template. This includes the Ticket Templates tab of a company or agreement. + NONE: |- + Restricts the ability to edit Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. + delete: + levels: + ALL: |- + Allows the ability to delete Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. + MY: |- + Allows the ability to delete Ticket Templates only where the member has access to the service board, location and group associated with the template. This includes the Ticket Templates tab of a company or agreement. + NONE: |- + Restricts the ability to delete Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. + inquire: + levels: + ALL: |- + Allows the ability to review Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. + MY: |- + Allows the ability to review Ticket Templates only where the member has access to the service board, location and group associated with the template. This includes the Ticket Templates tab of a company or agreement. + NONE: |- + Restricts the ability to review Ticket Templates at the Company Level. This includes the Ticket Templates tab of a company or agreement. Note: This does not control the application of Ticket Templates to existing tickets using the protractor icon. + System: + actions: + API Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the user to access the API Reporting Views when using an API Member. + NONE: |- + Restricts the ability to review the API Reporting Views when using an API Member. + Chat with ConnectWise Manage Support: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows access to the Chat with Support button in the top navigation menu. + NONE: Restricts access to the Chat with Support button in the top navigation menu. + ConnectWise Manage Labs: + add: + note: Not applicable. See Edit level. + edit: + levels: + ALL: Allows the ability to turn labs on and off + NONE: Restricts the ablility to turn labs on and off + delete: + note: Not applicable. See Edit level. + inquire: + note: Not applicable. See Edit level. + Custom Menu Entry: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review custom menu entries in the modules they are assigned to. By default, when this option is selected, access to all Custom Menu Entries is allowed, but access can be customized using the Customize Option for this module. + NONE: Restricts the ability to review existing Custom Menu Entries. + Email Audit: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows access to the Email Audit icon under the Service Desk module and the Email Audit tab on the Company screen + NONE: |- + Restricts access to the Email Audit icon under the Service Desk module and the Email Audit tab on the Company screen. + List View Export: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to export list view items to an Excel Spreadsheet. + NONE: Restricts the ability to export list view items to an Excel Spreadsheet. + Marketplace Sharing: + add: + levels: + ALL: Allows the ability to upload items to the ConnectWise Marketplace. + NONE: Restricts the ability to upload items to the ConnectWise Marketplace. + edit: + note: Not applicable. See Add and Inquire levels. + delete: + note: Not applicable. See Add and Inquire levels. + inquire: + levels: + ALL: Allows the ability to import items from the ConnectWise Marketplace. + NONE: Restricts the ability to import items from the ConnectWise Marketplace. + Mass Maintenance: + add: + levels: + ALL: |- + Allows the ability to add new items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. + NONE: Restricts the ability to add new items via the Mass Maintenance screens. + edit: + levels: + ALL: |- + Allows the ability to edit existing items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. + NONE: Restricts the ability to edit existing items via the Mass Maintenance screens. + delete: + levels: + ALL: |- + Allows the ability to delete existing items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. + NONE: Restricts the ability to delete existing items via the Mass Maintenance screens. + inquire: + levels: + ALL: |- + Allows the ability to review existing items via the Mass Maintenance screens. Access to each screen can be customized using the Customize Option for this module. + NONE: Restricts the ability to review existing items via the Mass Maintenance screens. + Member Maintenance: + add: + levels: + ALL: Allows the ability to create new member profiles. + NONE: Restricts the ability to create new member profiles. + edit: + levels: + ALL: Allows the ability to edit existing member profiles. + NONE: Restricts the ability to edit existing member profiles. + delete: + levels: + ALL: Allows the ability to delete existing member profiles. + NONE: Restricts the ability to deleteexisting member profiles. + inquire: + levels: + ALL: Allows the ability to review existing member profiles. + NONE: Restricts the ability to review existing member profiles. + My Account: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to edit the information on the MY Account Screen for all members. + MY: |- + Allows the ability to edit the information on the MY Account Screen for only the member logged in. + NONE: Retricts the ability to edit the information on the MY Account Screen + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: |- + Allows the ability to review the information on the MY Account Screen for all members. + MY: |- + Allows the ability to review the information on the MY Account Screen for only the member logged in. + NONE: Retricts the ability to review the information on the MY Account Screen + My Company: + add: + levels: + ALL: |- + Allows the ability to add new structure and group levels to the My Company Screen. + NONE: |- + Restricts the ability to add new structure and group levels to the My Company Screen. + edit: + levels: + ALL: Allows the ability to edit the information on the My Company Screen. + NONE: Restricts the ability to edit the information on the My Company Screen. + delete: + levels: + ALL: |- + Allows the ability to delete structure and group levels from the My Company Screen. + NONE: |- + Restricts the ability to delete structure and group levels from the My Company Screen + inquire: + levels: + ALL: Allows the ability to review the information on the My Company Screen. + NONE: Restricts the ability to review the information on the My Company Screen. + Report Writer: + add: + levels: + ALL: Allows access to the report writer designer + NONE: Restricts access to the report writer designer + edit: + note: Not applicable. See Add and Inquire levels. + delete: + note: Not applicable. See Add and Inquire levels. + inquire: + levels: + ALL: Allows access to view report writer reports and dashboards. + NONE: Restricts access to view report writer reports and dashboards. + Security Roles: + add: + levels: + ALL: Allows the ability to create new security roles. + NONE: Restricts the ability to create new security roles. + edit: + levels: + ALL: Allows the ability to edit the settings for existing security roles. + NONE: Restricts the ability to edit the settings for existing security roles. + delete: + levels: + ALL: Allows the ability to delete existing security roles. + NONE: Restricts the ability to delete existing security roles. + inquire: + levels: + ALL: Allows the ability to review the settings for existing security roles. + NONE: Restricts the ability to review the settings for existing security roles. + System Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the System Category of the reports module. By default, when this option is selected, access to all System reports is allowed, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the System Category of the reports module. + Table Setup: + add: + levels: + ALL: |- + Allows the ability to create new items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. + NONE: Restricts the ability to create new items within each of the Setup Tables. + edit: + levels: + ALL: |- + Allows the ability to edit existing items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. + NONE: Restricts the ability to edit existing items within each of the Setup Tables. + delete: + levels: + ALL: |- + Allows the ability to delete existing items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. + NONE: Restricts the ability to delete existing items within each of the Setup Tables. + inquire: + levels: + ALL: |- + Allows the ability to review existing items within each of the Setup Tables. By default, when this option is selected, access to all Setup Tables is allowed, but access can be customized using the Customize Option for this module. + NONE: Restricts the ability to review existing items within each of the Setup Tables. + Today Links: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: Allows the ability to view and browse to the links setup on the Today screen. + NONE: Restricts the ability to view and browse to the links setup on the Today screen. + Time and Expense: + actions: + Expense Approvals: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: |- + Allows the ability to approve expense reports and reverse expense report approvals. + NONE: |- + Restricts the ability to approve expense reports and reverse expense report approvals. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: Allows the ability to view expense reports submitted for approval. + NONE: Restricts the ability to view expense reports submitted for approval. + Expense Report Entry: + add: + levels: + ALL: |- + Allows the ability to add expense records within the system. This includes expense records for other members while on their Expense Report. + MY: Allows the ability to add expenses only for the member logged in. + NONE: Retricts the ability to add expense records within the system. + edit: + levels: + ALL: Allows the ability to edit all existing expense records within the system. + MY: |- + Allows the ability to edit only the expenses that particular member has entered within the system. + NONE: Retricts the ability to edit existing expenses within the system. + delete: + levels: + ALL: |- + Allows the ability to delete all existing expense records within the system. This includes expense records for other members. + MY: |- + Allows the ability to delete only the expenses that particular member has entered within the system. + NONE: Retricts the ability to delete existing expenses within the system. + inquire: + levels: + ALL: Allows the ability to review all existing expense records within the system. + MY: |- + Allows the ability to review only the expenses that particular member has entered within the system. + NONE: Retricts the ability to review existing expenses within the system. + Reports: + add: + note: Not applicable. See Inquire level. + edit: + note: Not applicable. See Inquire level. + delete: + note: Not applicable. See Inquire level. + inquire: + levels: + ALL: |- + Allows the ability to review reports located in the Time Entry Category of the reports module. By default, when this option is selected, access to all Time Entry reports is allowed, but access can be customized using the Customize Option for this module. + NONE: |- + Retricts the ability to review reports located in the Time Entry Category of the reports module. + Stopwatch: + add: + levels: + ALL: |- + Allows the ability to use the Stopwatch on both the All Tickets tab and in the Ticket screen. + NONE: Restricts the ability to use the Stopwatch on the All Tickets tab. + edit: + note: Not applicable. See Add and Inquire levels. + delete: + note: Not applicable. See Add and Inquire levels. + inquire: + levels: + ALL: |- + Allows the ability to view the Stopwatch on the All Tickets tab, and grants the ability to use the Stopwatch in the Ticket screen. + NONE: |- + Restricts the ability to view the Stopwatch in the Ticket screen or in the All Tickets tab. + Time Approval: + add: + note: Not applicable. See Edit and Inquire levels. + edit: + levels: + ALL: Allows the ability to approve/reject Time Sheet Approvals for all members. + MY: |- + Allows the ability to approve/reject Time Sheet Approvals for only the users the member logged in is the approver for. + NONE: Retricts the ability to approve/reject Time Sheet Approvals. + delete: + note: Not applicable. See Edit and Inquire levels. + inquire: + levels: + ALL: Allows the ability to review Time Sheet Approvals for all members. + MY: |- + Allows the ability to review Time Sheet Approvals for only the users the member logged in is the approver for. + NONE: Retricts the ability to review Time Sheet Approvals. + Time Entry: + add: + levels: + ALL: Allows the ability to create time entries for all members. + MY: Allows the ability tocreate time entries for only the member logged in. + NONE: Retricts the ability to create time entries. + edit: + levels: + ALL: Allows the ability to edit existing time entries for all members. + MY: Allows the ability to edit existing time entries for only the member logged in. + NONE: Retricts the ability to edit existing time entries. + delete: + levels: + ALL: Allows the ability to delete existing time entries for all members. + MY: |- + Allows the ability to delete existing time entries for only the member logged in. + NONE: Retricts the ability to delete existing time entries. + inquire: + levels: + ALL: Allows the ability to review existing time entries for all members. + MY: |- + Allows the ability to view existing time entries for only the member logged in. This includes the time tab and audit trail. Time entry notes will still appear in the notes pod and in the audit trail for all members. + NONE: Retricts the ability to review existing time entries. diff --git a/docs/connectwise/CW_Security_Roles/generate_role_docs.py b/docs/connectwise/CW_Security_Roles/generate_role_docs.py new file mode 100644 index 00000000..de3d8e44 --- /dev/null +++ b/docs/connectwise/CW_Security_Roles/generate_role_docs.py @@ -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() diff --git a/docs/connectwise/CW_Security_Roles/requirements.txt b/docs/connectwise/CW_Security_Roles/requirements.txt new file mode 100644 index 00000000..fd83a4c7 --- /dev/null +++ b/docs/connectwise/CW_Security_Roles/requirements.txt @@ -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 diff --git a/docs/superpowers/plans/2026-04-16-psa-ticket-management.md b/docs/superpowers/plans/2026-04-16-psa-ticket-management.md new file mode 100644 index 00000000..cefa932d --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-psa-ticket-management.md @@ -0,0 +1,3075 @@ +# PSA Ticket Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add PSA ticket management to ResolutionFlow — a dedicated Tickets page, an updated TicketQueue dashboard widget, and spin-off ticket creation from ResolutionAssist sessions. + +**Architecture:** Dedicated `ticket_service.py` wraps PSA provider mutations; new normalized DTOs in `psa_tickets.py`; `search_tickets` updated to return paginated results via parallel CW count fetch. Frontend uses URL params for filter/pagination state, `TicketDetailPanel` hydrates via existing `getTicketContext` + new `listResources` endpoint. + +**Tech Stack:** FastAPI, SQLAlchemy async, Pydantic v2, Anthropic SDK (AI parse), React 19, TypeScript, Tailwind v4, React Router v7 `useSearchParams`, Lucide icons. + +--- + +## Phase 1 — Backend: Provider Foundations + +### Task 1: Add PaginatedTicketResult type + update provider base + +**Files:** +- Modify: `backend/app/services/psa/types.py` +- Modify: `backend/app/services/psa/base.py` + +- [ ] **Step 1: Add PaginatedTicketResult to types.py** + +```python +# backend/app/services/psa/types.py — add after PSABoard class + +from dataclasses import dataclass + +@dataclass +class PaginatedTicketResult: + items: list["PSATicket"] + total: int + page: int + page_size: int +``` + +- [ ] **Step 2: Update search_tickets signature in base.py** + +```python +# backend/app/services/psa/base.py +from .types import ( + ConnectionTestResult, + PSATicket, + PaginatedTicketResult, # add + PSANote, + PSAStatus, + PSACompany, + PSAMember, + PSAConfiguration, + PSATimeEntry, + PSABoard, +) + +# Change the search_tickets abstract method: +@abstractmethod +async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: + ... +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/services/psa/types.py backend/app/services/psa/base.py +git commit -m "feat(psa): add PaginatedTicketResult type, update provider search_tickets signature" +``` + +--- + +### Task 2: Add new abstract provider methods + +**Files:** +- Modify: `backend/app/services/psa/types.py` +- Modify: `backend/app/services/psa/base.py` + +- [ ] **Step 1: Add PSAResource + PSACreatedTicket types to types.py** + +```python +# backend/app/services/psa/types.py — add after PaginatedTicketResult + +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 +``` + +- [ ] **Step 2: Add 4 abstract methods to base.py** + +```python +# backend/app/services/psa/base.py — add these after get_ticket_configurations + +@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: + ... +``` + +Also add `list_priorities` for the full-form dropdown: + +```python +@abstractmethod +async def list_priorities(self) -> list[dict]: + ... +``` + +- [ ] **Step 3: Update base.py imports** + +```python +from .types import ( + ConnectionTestResult, + PSATicket, + PaginatedTicketResult, + PSANote, + PSAStatus, + PSACompany, + PSAMember, + PSAConfiguration, + PSATimeEntry, + PSABoard, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, +) +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/psa/types.py backend/app/services/psa/base.py +git commit -m "feat(psa): add PSAResource, TicketCreatePayload types and abstract provider methods" +``` + +--- + +### Task 3: Implement new CW provider methods + +**Files:** +- Modify: `backend/app/services/psa/connectwise/provider.py` + +- [ ] **Step 1: Update CW search_tickets to return PaginatedTicketResult** + +In `ConnectWiseProvider.search_tickets()`, replace the final `return [...]` block with a parallel count fetch: + +```python +async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: + page_size = filters.get("page_size", 10) + page = filters.get("page", 1) + + params: dict = { + "fields": "id,summary,company,board,status,priority,closedFlag", + "orderBy": "priority/sort asc,dateEntered desc", + "pageSize": page_size, + "page": page, + } + + conditions: list[str] = [] + if query: + conditions.append(f"summary contains '{query}'") + if filters.get("board_id"): + conditions.append(f"board/id = {filters['board_id']}") + if filters.get("status_id"): + conditions.append(f"status/id = {filters['status_id']}") + if not filters.get("include_closed", False): + conditions.append("closedFlag = false") + if filters.get("member_identifier") is not None: + conditions.append(f"resources contains '{filters['member_identifier']}'") + if filters.get("unassigned", False): + conditions.append("resources = null") + board_ids: list[int] = filters.get("board_ids") or [] + if board_ids: + board_list = ", ".join(str(bid) for bid in board_ids) + conditions.append(f"board/id in ({board_list})") + + condition_str = " and ".join(conditions) if conditions else "" + if condition_str: + params["conditions"] = condition_str + + count_params: dict = {} + if condition_str: + count_params["conditions"] = condition_str + + # 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) +``` + +- [ ] **Step 2: Add update import in provider.py** + +```python +from app.services.psa.types import ( + ConnectionTestResult, + PSATicket, + PaginatedTicketResult, + PSANote, + PSAStatus, + PSACompany, + PSAMember, + PSAConfiguration, + PSATimeEntry, + PSABoard, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, +) +``` + +- [ ] **Step 3: Update _map_ticket to expose company_id and board_id** + +```python +@staticmethod +def _map_ticket(data: dict) -> 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.get("id", "")), + summary=data.get("summary", ""), + 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), + ) +``` + +- [ ] **Step 4: Implement list_resources** + +```python +async def list_resources(self, ticket_id: int) -> list[PSAResource]: + 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 +``` + +- [ ] **Step 5: Implement add_resource** + +```python +async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: + data = await self.client.post( + f"/service/tickets/{ticket_id}/members", + json={"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", ""), + ) +``` + +- [ ] **Step 6: Implement remove_resource** + +```python +async def remove_resource(self, ticket_id: int, member_id: int) -> None: + # CW DELETE /service/tickets/{id}/members requires the member record id, + # not the member id. Fetch the list first to find the record 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}") +``` + +- [ ] **Step 7: Implement create_ticket** + +```python +async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: + 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) + + # Fetch resources for the created ticket + ticket_id = data.get("id") + 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 {} + board = data.get("board") or {} + status = data.get("status") or {} + priority = data.get("priority") or {} + + return PSACreatedTicket( + id=ticket_id or 0, + summary=data.get("summary", payload.summary), + board_name=board.get("name", ""), + status_name=status.get("name", ""), + priority_name=priority.get("name", ""), + company_name=company.get("name", ""), + resources=resources, + ) +``` + +- [ ] **Step 8: Implement list_priorities** + +```python +async def list_priorities(self) -> list[dict]: + 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 []) + ] +``` + +- [ ] **Step 9: Commit** + +```bash +git add backend/app/services/psa/connectwise/provider.py +git commit -m "feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider" +``` + +--- + +### Task 4: Update PSATicketInfo schema + add psa_tickets.py schemas + +**Files:** +- Modify: `backend/app/schemas/psa_connection.py` +- Create: `backend/app/schemas/psa_tickets.py` + +- [ ] **Step 1: Add company_id and board_id to PSATicketInfo in psa_connection.py** + +The existing `PSATicketInfo` equivalent for API responses is `PSATicketSearchResult`. Add company_id/board_id fields and a new `PSATicketInfoFull` for the get_ticket endpoint: + +```python +# backend/app/schemas/psa_connection.py — update PSATicketSearchResult +class PSATicketSearchResult(BaseModel): + id: str + summary: str + company_name: str | None = None + company_id: str | None = None # add + board_name: str | None = None + board_id: int | None = None # add + status_name: str | None = None + status_id: int | None = None # add + priority_name: str | None = None + priority_id: int | None = None # add + closed: bool = False +``` + +- [ ] **Step 2: Create psa_tickets.py with all new schemas** + +```python +# backend/app/schemas/psa_tickets.py +"""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 # list[PSATicketSearchResult] — imported in endpoints + total: int + page: int + page_size: int + + +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 +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/schemas/psa_connection.py backend/app/schemas/psa_tickets.py +git commit -m "feat(psa): expand PSATicketSearchResult with IDs, add psa_tickets.py schemas" +``` + +--- + +## Phase 2 — Backend: ticket_service.py + Endpoints + +### Task 5: Create ticket_service.py + +**Files:** +- Create: `backend/app/services/ticket_service.py` + +- [ ] **Step 1: Write ticket_service.py** + +```python +# backend/app/services/ticket_service.py +"""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 + ], + ) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/services/ticket_service.py +git commit -m "feat(psa): add ticket_service.py with list/add/remove resource, update_status, create_ticket" +``` + +--- + +### Task 6: Update search endpoint + add new ticket endpoints + +**Files:** +- Modify: `backend/app/api/endpoints/integrations.py` + +- [ ] **Step 1: Add new imports at top of integrations.py** + +```python +from app.schemas.psa_tickets import ( + PSAResourceSchema, + PSATicketCreatedSchema, + PSATicketStatusUpdateSchema, + TicketCreatePayloadSchema, + AiParseRequestSchema, + AiParseResponseSchema, + PSAPrioritySchema, +) +import app.services.ticket_service as ticket_svc +``` + +- [ ] **Step 2: Update search_tickets endpoint to return paginated response** + +Replace the existing `@router.get("/tickets/search", ...)` endpoint with: + +```python +@router.get("/tickets/search") +async def search_tickets( + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], + query: str = "", + board_id: int | None = None, + status_id: int | None = None, + include_closed: bool = False, + 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 = 25, +): + """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 + + member_identifier: str | None = None + if assigned_to_me: + conn_result = await db.execute( + select(PsaConnection).where( + PsaConnection.account_id == current_user.account_id, + PsaConnection.is_active.is_(True), + ) + ) + conn = conn_result.scalar_one_or_none() + if conn: + mapping_result = await db.execute( + select(PsaMemberMapping).where( + PsaMemberMapping.psa_connection_id == conn.id, + PsaMemberMapping.user_id == current_user.id, + ) + ) + mapping = mapping_result.scalar_one_or_none() + if not mapping: + return {"items": [], "total": 0, "page": page, "page_size": page_size} + try: + _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 {"items": [], "total": 0, "page": page, "page_size": page_size} + except PSAError: + return {"items": [], "total": 0, "page": page, "page_size": page_size} + + parsed_board_ids: list[int] = [] + if board_ids: + try: + parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()] + except ValueError: + raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers") + + try: + provider = await get_provider_for_account(current_user.account_id, db) + result = await provider.search_tickets( + query, + board_id=board_id, + status_id=status_id, + include_closed=include_closed, + member_identifier=member_identifier, + unassigned=unassigned, + board_ids=parsed_board_ids, + company_id=company_id, + page=page, + page_size=page_size, + ) + 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 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)) +``` + +- [ ] **Step 3: Add POST /tickets endpoint** + +```python +@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)) +``` + +- [ ] **Step 4: Add PATCH /tickets/{id}/status endpoint** + +```python +@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)) +``` + +- [ ] **Step 5: Add resource CRUD endpoints** + +```python +@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)) +``` + +- [ ] **Step 6: Add GET /priorities endpoint** + +```python +@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 [] +``` + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/api/endpoints/integrations.py +git commit -m "feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints" +``` + +--- + +### Task 7: Add AI parse endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/integrations.py` + +- [ ] **Step 1: Add ai-parse endpoint** + +```python +@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 + from app.core.config import settings + 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 always need manual selection — they're board-dependent + 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 +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/api/endpoints/integrations.py +git commit -m "feat(psa): add AI ticket parse endpoint" +``` + +--- + +### Task 8: Update assistant_chat_service.py system prompt + +**Files:** +- Modify: `backend/app/services/assistant_chat_service.py` + +- [ ] **Step 1: Add spin-off ticket rule to ASSISTANT_SYSTEM_PROMPT** + +Find the end of `ASSISTANT_SYSTEM_PROMPT` in `assistant_chat_service.py` and append this section before the closing `"""`: + +```python +# Add as the last section of ASSISTANT_SYSTEM_PROMPT, before the closing triple-quote: + +## 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] +``` + +- [ ] **Step 2: Write a backend test for the new endpoints** + +```python +# 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 + 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 +``` + +- [ ] **Step 3: Run tests** + +```bash +cd backend && pytest tests/test_psa_tickets.py -v --override-ini="addopts=" +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/assistant_chat_service.py backend/tests/test_psa_tickets.py +git commit -m "feat(psa): add spin-off ticket system prompt rule, backend routing tests" +``` + +--- + +## Phase 3 — Frontend: Types + API Client + +### Task 9: Create types/tickets.ts + update types/integrations.ts + +**Files:** +- Create: `frontend/src/types/tickets.ts` +- Modify: `frontend/src/types/integrations.ts` + +- [ ] **Step 1: Create types/tickets.ts** + +```typescript +// frontend/src/types/tickets.ts +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 +} +``` + +- [ ] **Step 2: Update PSATicketSearchResult and PSATicketInfo in types/integrations.ts** + +```typescript +// frontend/src/types/integrations.ts — update these interfaces + +export interface PSATicketInfo { + id: string + summary: string + company_name: string | null + company_id: number | null // add + board_name: string | null + board_id: number | null // add + status_name: string | null + status_id: number | null // add + priority_name: string | null + priority_id: number | null // add +} + +export interface PSATicketSearchResult { + id: string + summary: string + company_name: string | null + company_id: string | null // add + board_name: string | null + board_id: number | null // add + status_name: string | null + status_id: number | null // add + priority_name: string | null + priority_id: number | null // add + closed: boolean +} +``` + +- [ ] **Step 3: Export new types from types/index.ts (if it exists)** + +```bash +grep -n "tickets" /home/coder/root-workspace/resolutionflow/frontend/src/types/index.ts +``` + +If `types/index.ts` exists and exports from other type files, add: +```typescript +export * from './tickets' +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/types/tickets.ts frontend/src/types/integrations.ts +git commit -m "feat(tickets): add tickets types, expand PSATicketSearchResult/PSATicketInfo with IDs" +``` + +--- + +### Task 10: Create api/tickets.ts + update api/integrations.ts + +**Files:** +- Create: `frontend/src/api/tickets.ts` +- Modify: `frontend/src/api/integrations.ts` + +- [ ] **Step 1: Create api/tickets.ts** + +```typescript +// frontend/src/api/tickets.ts +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), +} +``` + +- [ ] **Step 2: Update return types in api/integrations.ts** + +```typescript +// frontend/src/api/integrations.ts — update these two methods: +import type { TicketListResponse } from '@/types/tickets' + +searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) => + 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), +``` + +- [ ] **Step 3: Update TicketQueue.tsx and TicketPickerModal.tsx to read .items** + +In `frontend/src/components/dashboard/TicketQueue.tsx`, find every place that uses the result of `searchTicketsQueue()` or `searchTickets()` as an array and change it to read `.items`: + +```bash +grep -n "searchTicketsQueue\|searchTickets" /home/coder/root-workspace/resolutionflow/frontend/src/components/dashboard/TicketQueue.tsx +``` + +Change any `setTickets(data)` or similar to `setTickets(data.items)`. + +In `frontend/src/components/session/TicketPickerModal.tsx`: +```bash +grep -n "searchTickets" /home/coder/root-workspace/resolutionflow/frontend/src/components/session/TicketPickerModal.tsx +``` + +Change any usage from array result to `.items` access. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/api/tickets.ts frontend/src/api/integrations.ts \ + frontend/src/components/dashboard/TicketQueue.tsx \ + frontend/src/components/session/TicketPickerModal.tsx +git commit -m "feat(tickets): add tickets API client, update integrations API for paginated search" +``` + +--- + +## Phase 4 — Tickets Page + +### Task 11: Create TicketFilterBar.tsx + +**Files:** +- Create: `frontend/src/components/tickets/TicketFilterBar.tsx` + +- [ ] **Step 1: Create the config-driven filter bar** + +```tsx +// frontend/src/components/tickets/TicketFilterBar.tsx +import { Search, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TicketFilters } from '@/types/tickets' +import type { PSABoard, PSATicketStatusItem } from '@/types/integrations' +import type { PSAPriority } from '@/types/tickets' + +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`} + +
+ + +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/tickets/TicketFilterBar.tsx +git commit -m "feat(tickets): add config-driven TicketFilterBar with pagination controls" +``` + +--- + +### Task 12: Create TicketListRow.tsx + +**Files:** +- Create: `frontend/src/components/tickets/TicketListRow.tsx` + +- [ ] **Step 1: Create compact row component** + +```tsx +// 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-muted/10', 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 ?? '—'} + +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/tickets/TicketListRow.tsx +git commit -m "feat(tickets): add compact TicketListRow component" +``` + +--- + +### Task 13: Create TicketsPage.tsx + +**Files:** +- Create: `frontend/src/pages/TicketsPage.tsx` + +- [ ] **Step 1: Create TicketsPage with URL-param filter state** + +```tsx +// frontend/src/pages/TicketsPage.tsx +import { useEffect, useState, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Plus, Ticket } from 'lucide-react' +import { PageMeta } from '@/components/common/PageMeta' +import { TicketFilterBar } from '@/components/tickets/TicketFilterBar' +import { TicketListRow } from '@/components/tickets/TicketListRow' +import { TicketDetailPanel } from '@/components/tickets/TicketDetailPanel' +import { NewTicketModal } from '@/components/tickets/NewTicketModal' +import { integrationsApi } from '@/api/integrations' +import { ticketsApi } from '@/api/tickets' +import type { PSATicketSearchResult, PSABoard, PSATicketStatusItem } from '@/types/integrations' +import type { TicketFilters, PSAPriority } from '@/types/tickets' +import { DEFAULT_TICKET_FILTERS } from '@/types/tickets' + +const PAGE_SIZE = 25 + +function filtersFromParams(params: URLSearchParams): TicketFilters & { page: number } { + const assigned = params.get('assigned') ?? 'all' + return { + search: params.get('search') ?? '', + board_id: params.get('board') ? Number(params.get('board')) : null, + status_id: params.get('status') ? Number(params.get('status')) : null, + priority: params.get('priority') ?? null, + company_id: params.get('company') ? Number(params.get('company')) : null, + assigned: (assigned === 'me' || assigned === 'unassigned' || assigned === 'all') + ? assigned + : Number(assigned), + include_closed: params.get('closed') === 'true', + page: params.get('page') ? Number(params.get('page')) : 1, + } +} + +export default function TicketsPage() { + const [searchParams, setSearchParams] = useSearchParams() + const { page, ...filters } = filtersFromParams(searchParams) + + const [tickets, setTickets] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(false) + const [boards, setBoards] = useState([]) + const [statuses, setStatuses] = useState([]) + const [priorities, setPriorities] = useState([]) + const [members, setMembers] = useState<{ id: number; name: string }[]>([]) + const [selectedTicket, setSelectedTicket] = useState(null) + const [showNewTicket, setShowNewTicket] = useState(false) + + // Load filter option data once + useEffect(() => { + integrationsApi.listBoards().then(setBoards).catch(() => {}) + ticketsApi.listPriorities().then(setPriorities).catch(() => {}) + integrationsApi.listMembers() + .then(ms => setMembers(ms.map(m => ({ id: Number(m.id), name: m.name })))) + .catch(() => {}) + }, []) + + // Load statuses when board changes + useEffect(() => { + if (filters.board_id) { + integrationsApi.getTicketStatuses(String(filters.board_id)) + .then(setStatuses).catch(() => {}) + } else { + setStatuses([]) + } + }, [filters.board_id]) + + // Fetch tickets on filter/page change + const fetchTickets = useCallback(async () => { + setLoading(true) + try { + const result = await ticketsApi.searchTickets({ + query: filters.search || undefined, + board_id: filters.board_id ?? undefined, + status_id: filters.status_id ?? undefined, + include_closed: filters.include_closed, + assigned_to_me: filters.assigned === 'me', + unassigned: filters.assigned === 'unassigned', + priority: filters.priority ?? undefined, + company_id: filters.company_id ?? undefined, + page, + page_size: PAGE_SIZE, + }) + setTickets(result.items) + setTotal(result.total) + } catch { + setTickets([]) + setTotal(0) + } finally { + setLoading(false) + } + }, [filters.search, filters.board_id, filters.status_id, filters.include_closed, + filters.assigned, filters.priority, filters.company_id, page]) + + useEffect(() => { fetchTickets() }, [fetchTickets]) + + function updateFilters(updated: Partial) { + const next = new URLSearchParams(searchParams) + if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search') + if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board') + if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status') + if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority') + if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company') + if ('assigned' in updated) { + const a = updated.assigned + a === 'all' ? next.delete('assigned') : next.set('assigned', String(a)) + } + if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed') + next.delete('page') // reset to 1 on filter change + setSearchParams(next) + } + + function updatePage(p: number) { + const next = new URLSearchParams(searchParams) + p === 1 ? next.delete('page') : next.set('page', String(p)) + setSearchParams(next) + } + + return ( +
+ + + {/* Header */} +
+
+ +

Tickets

+
+ +
+ + {/* 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() }} + /> + )} +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/pages/TicketsPage.tsx +git commit -m "feat(tickets): add TicketsPage with URL-param filter state and slide-out detail" +``` + +--- + +### Task 14: Add route + sidebar nav + +**Files:** +- Modify: `frontend/src/router.tsx` +- Modify: `frontend/src/components/layout/Sidebar.tsx` + +- [ ] **Step 1: Add TicketsPage to router.tsx** + +After the existing lazy imports, add: +```typescript +const TicketsPage = lazyWithRetry(() => import('@/pages/TicketsPage')) +``` + +Inside the protected route children array (alongside other routes like `/sessions`), add: +```typescript +{ path: 'tickets', element: }> }, +``` + +- [ ] **Step 2: Add Tickets nav item to Sidebar.tsx** + +In `Sidebar.tsx`, find the `railGroups` array. Add a Tickets entry in the RESOLVE section. Find the History entry: +```typescript +{ + href: '/sessions', icon: History, label: 'History', shortLabel: 'History', + ... +}, +``` + +Add after it: +```typescript +{ + href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', + matchPaths: ['/tickets'], +}, +``` + +Also add `Ticket` to the Lucide imports at the top of `Sidebar.tsx`. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/router.tsx frontend/src/components/layout/Sidebar.tsx +git commit -m "feat(tickets): add /tickets route and sidebar nav item" +``` + +--- + +## Phase 5 — Ticket Detail Panel + +### Task 15: Create detail subcomponents + +**Files:** +- Create: `frontend/src/components/tickets/detail/TicketDetailHeader.tsx` +- Create: `frontend/src/components/tickets/detail/TicketNotesFeed.tsx` +- Create: `frontend/src/components/tickets/detail/TicketAddNote.tsx` +- Create: `frontend/src/components/tickets/detail/TicketConfigs.tsx` +- Create: `frontend/src/components/tickets/detail/TicketRelated.tsx` + +- [ ] **Step 1: Create TicketDetailHeader.tsx** + +```tsx +// frontend/src/components/tickets/detail/TicketDetailHeader.tsx +import { ExternalLink } from 'lucide-react' +import type { PSATicketSearchResult } from '@/types/integrations' +import type { PSATicketStatusUpdate } from '@/types/tickets' +import type { PSATicketStatusItem } from '@/types/integrations' +import { ticketsApi } from '@/api/tickets' +import { toast } from '@/lib/toast' +import { useState } from 'react' + +interface Props { + ticket: PSATicketSearchResult + statuses: PSATicketStatusItem[] + onStatusUpdated: (ticketId: number, newStatus: string) => void +} + +export function TicketDetailHeader({ ticket, statuses, onStatusUpdated }: Props) { + const [updating, setUpdating] = useState(false) + + async function handleStatusChange(statusId: number) { + if (!ticket.id) return + setUpdating(true) + try { + const result: PSATicketStatusUpdate = await ticketsApi.updateStatus(Number(ticket.id), statusId) + onStatusUpdated(result.ticket_id, result.new_status) + toast.success(`Status updated to ${result.new_status}`) + } catch { + toast.error('Failed to update status') + } finally { + setUpdating(false) + } + } + + return ( +
+
+
+
+ #{ticket.id} + {ticket.board_name && ( + {ticket.board_name} + )} +
+

+ {ticket.summary} +

+ {ticket.company_name && ( +

{ticket.company_name}

+ )} +
+
+ + {/* Status + Priority */} +
+ {statuses.length > 0 ? ( + + ) : ( + ticket.status_name && ( + + {ticket.status_name} + + ) + )} + {ticket.priority_name && ( + + {ticket.priority_name} + + )} +
+
+ ) +} +``` + +- [ ] **Step 2: Create TicketNotesFeed.tsx** + +```tsx +// frontend/src/components/tickets/detail/TicketNotesFeed.tsx +import type { TicketNote } from '@/api/psaContext' + +interface Props { notes: TicketNote[] } + +export function TicketNotesFeed({ notes }: Props) { + if (notes.length === 0) { + return

No notes yet.

+ } + return ( +
+ {notes.map((note, i) => ( +
+
+ {note.member ?? 'Unknown'} + {new Date(note.date_created).toLocaleDateString()} +
+ {note.internal_analysis_flag && ( + Internal + )} +

{note.text}

+
+ ))} +
+ ) +} +``` + +- [ ] **Step 3: Create TicketAddNote.tsx** + +```tsx +// frontend/src/components/tickets/detail/TicketAddNote.tsx +import { useState } from 'react' +import { sessionPsaApi } from '@/api/integrations' +import { toast } from '@/lib/toast' + +interface Props { + ticketId: string + sessionId?: string + onPosted: () => void +} + +export function TicketAddNote({ ticketId, sessionId, onPosted }: Props) { + const [text, setText] = useState('') + const [noteType, setNoteType] = useState<'internal_analysis' | 'description'>('internal_analysis') + const [posting, setPosting] = useState(false) + + // Note posting requires a session link — if no session, show info + 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 { + await sessionPsaApi.postToTicket(sessionId!, { + note_type: noteType, + update_status_id: undefined, + }) + setText('') + toast.success('Note posted to ticket') + onPosted() + } catch { + toast.error('Failed to post note') + } finally { + setPosting(false) + } + } + + return ( +
+ +