feat: ConnectWise PSA integration (#106)

PSA abstraction layer with provider pattern, ConnectWise integration (connection management, ticket linking, note posting, status updates, member mapping), Integrations page UI, Fernet credential encryption, in-memory TTL cache, 6 DB migrations, ConnectWise API reference docs.
This commit was merged in pull request #106.
This commit is contained in:
chihlasm
2026-03-15 01:45:35 -04:00
committed by GitHub
parent 80e094215f
commit 46865882c6
60 changed files with 726716 additions and 11 deletions

View File

@@ -53,18 +53,19 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie
## Current State
- **Phase:** Phase 2.5 - Step Library Foundation (In Progress)
- **Phase:** Phase 3 - PSA Integration (In Progress)
- **Backend:** Complete (35+ API endpoints, 100+ integration tests)
- **Frontend:** Core features complete, Tree Editor functional
- **Database:** PostgreSQL with Docker, 49 migrations
- **Database:** PostgreSQL with Docker, 75 migrations
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
### What's In Progress
- Step Library Frontend UI
- ConnectWise PSA Integration (ticket linking, note posting, member mapping, status updates)
### Recently Completed
- Step Library Foundation
- AI chat session conclusion: outcome tracking, AI-generated ticket summaries, resume flow
- Survey completion: email-to-self, thank-you page, admin read/unread/archive/delete management
- Survey system: public survey page, admin invite tracking, response viewer with CSV export
@@ -105,12 +106,13 @@ patherly/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat)
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, psa_connections)
│ │ ├── api/deps.py # Auth dependencies
│ │ ├── api/router.py # Route registration
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
│ │ ├── models/ # SQLAlchemy models
│ │ ── schemas/ # Pydantic schemas
│ │ ── schemas/ # Pydantic schemas
│ │ └── services/psa/ # PSA provider abstraction (base, connectwise/, cache, encryption, registry, types)
│ ├── alembic/ # Database migrations (001-029+)
│ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests
@@ -189,10 +191,14 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
### Key Implementation Rules
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
- All ConnectWise integration code belongs in a dedicated service layer (e.g., `services/connectwise/`) — do NOT scatter CW API calls across the codebase
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url`
- All PSA integration code in `services/psa/` — provider pattern with `BasePsaProvider` abstract class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch
- PSA endpoints in `api/endpoints/psa_connections.py` — connection CRUD, ticket ops, member mapping
- Credentials encrypted at rest via `services/psa/encryption.py` (Fernet)
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
- Design for the Autotask integration following the same service layer pattern (future PSA)
- Respect CW API: cache board/status/priority lookups, paginate with max 1000 per page, handle retries gracefully
- In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
- Respect CW API: paginate with max 1000 per page, handle retries gracefully
---
@@ -410,6 +416,8 @@ navigate(`/trees/${newTree.id}/edit`)
**58. `scriptGeneratorStore.generate()` has an optional `sessionId` param:** `generate(sessionId?: string)` — do NOT pass it as a bare `onClick={generate}` handler (TypeScript error: MouseEvent not assignable to string). Always wrap: `onClick={() => generate()}`.
**59. ConnectWise `clientId` is server-side config, not per-connection:** `clientId` is set in backend `config.py` as `CW_CLIENT_ID` — it identifies the ResolutionFlow integration app, not the MSP tenant. Per-connection credentials are only `company_id`, `public_key`, `private_key`, and `server_url`.
---
## RBAC & Permissions
@@ -505,8 +513,8 @@ When a feature, fix, or significant piece of work is finished and merged/committ
## Future Roadmap
- **Phase 3:** File attachments, offline mode, client context, analytics
- **Phase 4:** PSA integrations (ConnectWise, Kaseya), PowerShell automation, enterprise SSO
- **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics
- **Phase 4:** Additional PSA integrations (Autotask/Kaseya), PowerShell automation, enterprise SSO
---

View File

@@ -19,6 +19,9 @@ from app.models.survey_invite import SurveyInvite
from app.models.ai_suggestion import AISuggestion # noqa: F401
from app.models.kb_import import KBImport, KBImportNode # noqa: F401
from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401
from app.models.psa_connection import PsaConnection # noqa: F401
from app.models.psa_post_log import PsaPostLog # noqa: F401
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
from app.core.config import settings
# this is the Alembic Config object

View File

@@ -0,0 +1,39 @@
"""Add psa_connections table.
Revision ID: 058
Revises: 057
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "058"
down_revision = "057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"psa_connections",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("account_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("provider", sa.String(50), nullable=False),
sa.Column("display_name", sa.String(100), nullable=False),
sa.Column("site_url", sa.String(255), nullable=False),
sa.Column("company_id", sa.String(100), nullable=False),
sa.Column("credentials_encrypted", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"),
sa.UniqueConstraint("account_id"),
)
op.create_index("ix_psa_connections_account_id", "psa_connections", ["account_id"])
def downgrade() -> None:
op.drop_index("ix_psa_connections_account_id")
op.drop_table("psa_connections")

View File

@@ -0,0 +1,31 @@
"""Add psa_ticket_id and psa_connection_id to sessions.
Revision ID: 059
Revises: 058
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "059"
down_revision = "058"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sessions", sa.Column("psa_ticket_id", sa.String(100), nullable=True))
op.add_column(
"sessions",
sa.Column(
"psa_connection_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("sessions", "psa_connection_id")
op.drop_column("sessions", "psa_ticket_id")

View File

@@ -0,0 +1,57 @@
"""Add psa_post_log table for PSA note posting audit trail.
Revision ID: 060
Revises: 059
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "060"
down_revision = "059"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"psa_post_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"psa_connection_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("ticket_id", sa.String(100), nullable=False),
sa.Column("note_type", sa.String(50), nullable=False),
sa.Column("content_posted", sa.Text(), nullable=False),
sa.Column("external_note_id", sa.String(100), nullable=True),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("status_changed_from", sa.String(100), nullable=True),
sa.Column("status_changed_to", sa.String(100), nullable=True),
sa.Column(
"posted_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column(
"posted_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("psa_post_log")

View File

@@ -0,0 +1,60 @@
"""Add psa_member_mappings table for user-to-CW-member mapping.
Revision ID: 061
Revises: 060
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "061"
down_revision = "060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"psa_member_mappings",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"psa_connection_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("psa_connections.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("external_member_id", sa.String(100), nullable=False),
sa.Column("external_member_name", sa.String(200), nullable=False),
sa.Column("matched_by", sa.String(50), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint(
"psa_connection_id", "user_id",
name="uq_psa_member_mapping_connection_user",
),
sa.UniqueConstraint(
"psa_connection_id", "external_member_id",
name="uq_psa_member_mapping_connection_member",
),
)
def downgrade() -> None:
op.drop_table("psa_member_mappings")

View File

@@ -0,0 +1,565 @@
"""PSA integration endpoints — connection CRUD and test."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
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
from app.models.psa_member_mapping import PsaMemberMapping
from app.models.user import User
from app.schemas.psa_connection import (
PsaConnectionCreate,
PsaConnectionResponse,
PsaConnectionTestResponse,
PsaConnectionUpdate,
PSATicketSearchResult,
PSATicketStatusItem,
PsaMemberMappingResponse,
PsaMemberMappingSaveRequest,
PsaMemberResponse,
AutoMatchResult,
)
from app.core.config import settings
from app.services.psa.encryption import (
decrypt_credentials,
encrypt_credentials,
mask_credential,
)
router = APIRouter(prefix="/integrations/psa", tags=["integrations"])
# ── helpers ──────────────────────────────────────────────────────────
def _to_response(conn: PsaConnection) -> PsaConnectionResponse:
"""Build a response DTO with masked credential hints."""
creds = decrypt_credentials(conn.credentials_encrypted)
return PsaConnectionResponse(
id=conn.id,
account_id=conn.account_id,
provider=conn.provider,
display_name=conn.display_name,
site_url=conn.site_url,
company_id=conn.company_id,
is_active=conn.is_active,
last_validated_at=conn.last_validated_at,
created_at=conn.created_at,
updated_at=conn.updated_at,
public_key_hint=mask_credential(creds.get("public_key")),
private_key_hint=mask_credential(creds.get("private_key")),
)
async def _get_connection(
account_id: UUID, db: AsyncSession
) -> PsaConnection | None:
result = await db.execute(
select(PsaConnection).where(PsaConnection.account_id == account_id)
)
return result.scalar_one_or_none()
async def _test_credentials(
provider: str,
site_url: str,
company_id: str,
public_key: str,
private_key: str,
client_id: str,
) -> PsaConnectionTestResponse:
"""Instantiate a provider and run test_connection."""
if provider == "connectwise":
from app.services.psa.connectwise.client import ConnectWiseClient
from app.services.psa.connectwise.provider import ConnectWiseProvider
client = ConnectWiseClient(
site_url=site_url,
company_id=company_id,
public_key=public_key,
private_key=private_key,
client_id=client_id,
)
result = await ConnectWiseProvider(client).test_connection()
return PsaConnectionTestResponse(
success=result.success,
message=result.message,
server_version=result.server_version,
)
return PsaConnectionTestResponse(
success=False,
message=f"Unsupported provider: {provider}",
)
# ── endpoints ────────────────────────────────────────────────────────
@router.get("/connections", response_model=PsaConnectionResponse | None)
async def get_connection(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Return the account's PSA connection (redacted credentials) or null."""
if not current_user.account_id:
return None
conn = await _get_connection(current_user.account_id, db)
if not conn:
return None
return _to_response(conn)
@router.post(
"/connections",
response_model=PsaConnectionResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_connection(
data: PsaConnectionCreate,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new PSA connection. Tests credentials before saving."""
if not current_user.account_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No account associated with user")
if not settings.CW_CLIENT_ID:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "ConnectWise integration is not configured on this server")
# Check for existing connection
existing = await _get_connection(current_user.account_id, db)
if existing:
raise HTTPException(
status.HTTP_409_CONFLICT,
"A PSA connection already exists for this account. Update or delete the existing one.",
)
# Test connection before saving
test_result = await _test_credentials(
provider=data.provider,
site_url=data.site_url,
company_id=data.company_id,
public_key=data.public_key,
private_key=data.private_key,
client_id=settings.CW_CLIENT_ID,
)
if not test_result.success:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"Connection test failed: {test_result.message}",
)
credentials = {
"public_key": data.public_key,
"private_key": data.private_key,
}
conn = PsaConnection(
account_id=current_user.account_id,
provider=data.provider,
display_name=data.display_name,
site_url=data.site_url,
company_id=data.company_id,
credentials_encrypted=encrypt_credentials(credentials),
is_active=True,
last_validated_at=datetime.now(timezone.utc),
)
db.add(conn)
await db.commit()
await db.refresh(conn)
return _to_response(conn)
@router.put("/connections/{connection_id}", response_model=PsaConnectionResponse)
async def update_connection(
connection_id: UUID,
data: PsaConnectionUpdate,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update an existing PSA connection. Re-tests if credentials change."""
conn = await _get_connection_or_404(connection_id, current_user, db)
# Decrypt existing credentials
creds = decrypt_credentials(conn.credentials_encrypted)
# Track whether credential fields changed
cred_fields = {"public_key", "private_key"}
cred_changed = False
# Apply updates
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field in cred_fields:
if value is not None and value != creds.get(field):
creds[field] = value
cred_changed = True
else:
setattr(conn, field, value)
# Re-test if credentials changed
if cred_changed:
site_url = update_data.get("site_url", conn.site_url)
company_id_val = update_data.get("company_id", conn.company_id)
test_result = await _test_credentials(
provider=conn.provider,
site_url=site_url,
company_id=company_id_val,
public_key=creds["public_key"],
private_key=creds["private_key"],
client_id=settings.CW_CLIENT_ID or "",
)
if not test_result.success:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"Connection test failed: {test_result.message}",
)
conn.credentials_encrypted = encrypt_credentials(creds)
conn.last_validated_at = datetime.now(timezone.utc)
conn.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(conn)
return _to_response(conn)
@router.delete(
"/connections/{connection_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_connection(
connection_id: UUID,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Delete a PSA connection."""
conn = await _get_connection_or_404(connection_id, current_user, db)
await db.delete(conn)
await db.commit()
@router.post(
"/connections/{connection_id}/test",
response_model=PsaConnectionTestResponse,
)
async def test_connection(
connection_id: UUID,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Test an existing PSA connection."""
conn = await _get_connection_or_404(connection_id, current_user, db)
creds = decrypt_credentials(conn.credentials_encrypted)
result = await _test_credentials(
provider=conn.provider,
site_url=conn.site_url,
company_id=conn.company_id,
public_key=creds["public_key"],
private_key=creds["private_key"],
client_id=settings.CW_CLIENT_ID or "",
)
if result.success:
conn.last_validated_at = datetime.now(timezone.utc)
await db.commit()
# Invalidate cached PSA data when connection is re-validated
from app.services.psa.cache import psa_cache
psa_cache.clear()
return result
# ── ticket / status / company endpoints ──────────────────────────
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
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,
):
"""Search ConnectWise tickets."""
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)
tickets = await provider.search_tickets(
query, board_id=board_id, status_id=status_id, include_closed=include_closed
)
return [
PSATicketSearchResult(
id=t.id,
summary=t.summary,
company_name=t.company_name,
board_name=t.board_name,
status_name=t.status_name,
priority_name=t.priority_name,
closed=t.closed,
)
for t in tickets
]
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/{ticket_id}")
async def get_ticket(
ticket_id: str,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a single CW ticket by ID."""
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, PSANotFoundError
try:
provider = await get_provider_for_account(current_user.account_id, db)
ticket = await provider.get_ticket(ticket_id)
return ticket
except PSANotFoundError:
raise HTTPException(status_code=404, detail="Ticket not found")
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/{ticket_id}/statuses", response_model=list[PSATicketStatusItem])
async def get_ticket_statuses(
ticket_id: str,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get available statuses for a ticket's board."""
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, PSANotFoundError
try:
provider = await get_provider_for_account(current_user.account_id, db)
ticket = await provider.get_ticket(ticket_id)
if not ticket.board_id:
raise HTTPException(status_code=400, detail="Ticket has no board")
statuses = await provider.get_ticket_statuses(ticket.board_id)
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
except PSANotFoundError:
raise HTTPException(status_code=404, detail="Ticket not found")
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
# ── member mapping endpoints ─────────────────────────────────────────
@router.get("/members", response_model=list[PsaMemberResponse])
async def list_members(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List CW members (from CW API)."""
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)
members = await provider.list_members()
return [
PsaMemberResponse(id=m.id, identifier=m.identifier, name=m.name, email=m.email)
for m in members
]
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
async def get_member_mappings(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get all member mappings for the account."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
return []
result = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
mappings = result.scalars().all()
response = []
for m in mappings:
user_result = await db.execute(select(User).where(User.id == m.user_id))
user = user_result.scalar_one_or_none()
if user:
response.append(PsaMemberMappingResponse(
id=str(m.id),
user_id=str(m.user_id),
user_email=user.email,
user_name=user.name,
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
matched_by=m.matched_by,
))
return response
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
async def save_member_mappings(
mappings: list[PsaMemberMappingSaveRequest],
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Save/update member mappings (batch). Replaces all existing mappings."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
raise HTTPException(status_code=400, detail="No PSA connection configured")
# Delete existing mappings
await db.execute(
delete(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
# Insert new mappings
for m in mappings:
mapping = PsaMemberMapping(
psa_connection_id=conn.id,
user_id=UUID(m.user_id),
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
matched_by="manual_admin",
)
db.add(mapping)
await db.commit()
# Return the saved mappings
return await get_member_mappings(current_user, db)
@router.post("/member-mappings/auto-match", response_model=AutoMatchResult)
async def auto_match_members(
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Auto-match RF users to CW members by email."""
conn = await _get_account_connection(current_user.account_id, db)
if not conn:
raise HTTPException(status_code=400, detail="No PSA connection configured")
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)
cw_members = await provider.list_members()
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
# Build email → member lookup
email_to_member: dict = {}
for m in cw_members:
if m.email:
email_to_member[m.email.lower()] = m
# Get account users
users_result = await db.execute(
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
)
users = users_result.scalars().all()
matched = []
unmatched_count = 0
for user in users:
cw_member = email_to_member.get(user.email.lower())
if cw_member:
# Check if mapping already exists
existing = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == conn.id,
PsaMemberMapping.user_id == user.id,
)
)
if not existing.scalar_one_or_none():
mapping = PsaMemberMapping(
psa_connection_id=conn.id,
user_id=user.id,
external_member_id=cw_member.id,
external_member_name=cw_member.name,
matched_by="auto_email",
)
db.add(mapping)
matched.append((mapping, user))
else:
unmatched_count += 1
await db.commit()
# Build response
matched_response = [
PsaMemberMappingResponse(
id=str(m.id),
user_id=str(m.user_id),
user_email=u.email,
user_name=u.name,
external_member_id=m.external_member_id,
external_member_name=m.external_member_name,
matched_by=m.matched_by,
)
for m, u in matched
]
return AutoMatchResult(matched=matched_response, unmatched_users=unmatched_count)
# ── internal helpers ─────────────────────────────────────────────────
async def _get_account_connection(
account_id: UUID | None, db: AsyncSession
) -> PsaConnection | None:
"""Get the PSA connection for an account."""
if not account_id:
return None
result = await db.execute(
select(PsaConnection).where(PsaConnection.account_id == account_id)
)
return result.scalar_one_or_none()
async def _get_connection_or_404(
connection_id: UUID, user: User, db: AsyncSession
) -> PsaConnection:
"""Fetch a connection by ID, ensuring it belongs to the user's account."""
result = await db.execute(
select(PsaConnection).where(PsaConnection.id == connection_id)
)
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status.HTTP_404_NOT_FOUND, "PSA connection not found")
if conn.account_id != user.account_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "PSA connection not found")
return conn

View File

@@ -23,8 +23,12 @@ from app.schemas.session import (
SessionComplete,
SessionVariablesUpdate,
PrepareSessionRequest,
TicketLinkRequest,
TicketLinkResponse,
PSATicketResponse,
)
from app.api.deps import get_current_active_user
from app.schemas.psa_connection import PsaPostRequest
from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
@@ -738,3 +742,382 @@ async def batch_launch_sessions(
for s in created_sessions
],
)
# ── PSA Ticket Link ─────────────────────────────────────────────────
@router.patch("/{session_id}/ticket-link", response_model=TicketLinkResponse)
async def link_ticket(
session_id: UUID,
data: TicketLinkRequest,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Link or unlink a PSA ticket to/from a session."""
from app.models.psa_connection import PsaConnection
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSANotFoundError, PSAError
# Look up session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Verify ownership or admin
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session",
)
# Unlink
if data.psa_ticket_id is None:
session.psa_ticket_id = None
session.psa_connection_id = None
await db.commit()
return TicketLinkResponse(
session_id=str(session.id),
psa_ticket_id=None,
ticket=None,
)
# Link — validate ticket exists in CW
if not current_user.account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No account associated with your user",
)
try:
provider = await get_provider_for_account(current_user.account_id, db)
except PSAError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# Fetch the connection to store its ID
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
psa_connection = conn_result.scalar_one_or_none()
try:
ticket = await provider.get_ticket(data.psa_ticket_id)
except PSANotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found in ConnectWise",
)
except PSAError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"PSA error: {exc}",
)
session.psa_ticket_id = ticket.id
session.psa_connection_id = psa_connection.id if psa_connection else None
await db.commit()
return TicketLinkResponse(
session_id=str(session.id),
psa_ticket_id=ticket.id,
ticket=PSATicketResponse(
id=ticket.id,
summary=ticket.summary,
company_name=ticket.company_name,
board_name=ticket.board_name,
status_name=ticket.status_name,
priority_name=ticket.priority_name,
),
)
# ── PSA Post to Ticket ────────────────────────────────────────────
@router.get("/{session_id}/psa-post/preview")
async def psa_post_preview(
session_id: UUID,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Preview the content that will be posted to the linked PSA ticket.
Generates session documentation in PSA format, fetches current ticket
details and available statuses, and counts previous posts.
"""
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
from app.schemas.psa_connection import (
PsaPreviewResponse,
PSATicketSearchResult,
PSATicketStatusItem,
)
from sqlalchemy import func as sa_func
# Load session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
if not session.psa_ticket_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Session has no linked PSA ticket. Link a ticket first.",
)
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account associated with your user")
# Generate PSA export content
export_options = SessionExport(
format="psa",
include_timestamps=True,
include_tree_info=True,
include_outcome_notes=True,
include_next_steps=True,
include_summary=True,
)
content = generate_psa_export(session, export_options)
# Resolve session variables in content
session_vars = getattr(session, "session_variables", None) or {}
if session_vars:
from app.services.variable_service import resolve_variables
content = resolve_variables(content, session_vars)
# Fetch ticket details and statuses from CW
try:
provider = await get_provider_for_account(current_user.account_id, db)
ticket = await provider.get_ticket(session.psa_ticket_id)
available_statuses: list[PSATicketStatusItem] = []
if ticket.board_id:
statuses = await provider.get_ticket_statuses(ticket.board_id)
available_statuses = [
PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed)
for s in statuses
]
except PSAError as e:
raise HTTPException(status_code=502, detail=f"PSA error: {e}")
# Count previous posts
count_result = await db.execute(
select(sa_func.count(PsaPostLog.id)).where(
PsaPostLog.session_id == session_id
)
)
previous_posts = count_result.scalar_one()
return PsaPreviewResponse(
content=content,
ticket=PSATicketSearchResult(
id=ticket.id,
summary=ticket.summary,
company_name=ticket.company_name,
board_name=ticket.board_name,
status_name=ticket.status_name,
priority_name=ticket.priority_name,
closed=ticket.closed,
),
available_statuses=available_statuses,
character_count=len(content),
previous_posts=previous_posts,
)
@router.post("/{session_id}/psa-post")
async def psa_post_to_ticket(
session_id: UUID,
data: PsaPostRequest,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Post session documentation as a note to the linked PSA ticket.
Optionally updates the ticket status if update_status_id is provided.
All actions are logged in psa_post_log for audit trail.
"""
from app.models.psa_connection import PsaConnection
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
from app.schemas.psa_connection import PsaPostResponse
# Load session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
if not session.psa_ticket_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Session has no linked PSA ticket. Link a ticket first.",
)
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account associated with your user")
# Get PSA connection ID for audit
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
psa_connection = conn_result.scalar_one_or_none()
# Look up member mapping for attribution
from app.models.psa_member_mapping import PsaMemberMapping
member_id = None
if psa_connection:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == psa_connection.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if mapping:
member_id = mapping.external_member_id
# Post note
try:
provider = await get_provider_for_account(current_user.account_id, db)
note_result = await provider.post_note(
ticket_id=session.psa_ticket_id,
text=data.content,
note_type=data.note_type,
member_id=member_id,
)
note_status = "success"
external_note_id = note_result.id
error_message = None
except PSAError as e:
note_status = "failed"
external_note_id = None
error_message = str(e)
# Optionally update ticket status
status_changed_from = None
status_changed_to = None
if data.update_status_id and note_status == "success":
try:
# Get current status before update
current_ticket = await provider.get_ticket(session.psa_ticket_id)
status_changed_from = current_ticket.status_name
if current_ticket.status_id != data.update_status_id:
updated_ticket = await provider.update_ticket_status(
session.psa_ticket_id, data.update_status_id
)
status_changed_to = updated_ticket.status_name
except PSAError as e:
# Log the status update failure but don't fail the whole request
# since the note was already posted successfully
if error_message:
error_message += f"; Status update failed: {e}"
else:
error_message = f"Note posted successfully but status update failed: {e}"
# Log to audit trail
log_entry = PsaPostLog(
session_id=session.id,
psa_connection_id=psa_connection.id if psa_connection else None,
ticket_id=session.psa_ticket_id,
note_type=data.note_type,
content_posted=data.content,
external_note_id=external_note_id,
status=note_status,
error_message=error_message,
status_changed_from=status_changed_from,
status_changed_to=status_changed_to,
posted_by=current_user.id,
)
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
if note_status == "failed":
raise HTTPException(
status_code=502,
detail=error_message or "Failed to post note to PSA",
)
return PsaPostResponse(
id=str(log_entry.id),
session_id=str(session.id),
ticket_id=session.psa_ticket_id,
note_type=data.note_type,
status=note_status,
external_note_id=external_note_id,
error_message=error_message,
status_changed_from=status_changed_from,
status_changed_to=status_changed_to,
posted_at=log_entry.posted_at.isoformat(),
)
@router.get("/{session_id}/psa-posts")
async def list_psa_posts(
session_id: UUID,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all PSA post history for a session, ordered by most recent first."""
from app.models.psa_post_log import PsaPostLog
from app.schemas.psa_connection import PsaPostLogResponse
# Verify session access
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
# Query post log
log_result = await db.execute(
select(PsaPostLog)
.where(PsaPostLog.session_id == session_id)
.order_by(PsaPostLog.posted_at.desc())
)
logs = log_result.scalars().all()
return [
PsaPostLogResponse(
id=str(log.id),
ticket_id=log.ticket_id,
note_type=log.note_type,
status=log.status,
error_message=log.error_message,
status_changed_from=log.status_changed_from,
status_changed_to=log.status_changed_to,
posted_at=log.posted_at.isoformat(),
content_preview=log.content_posted[:200],
)
for log in logs
]

View File

@@ -17,6 +17,7 @@ from app.api.endpoints import ai_suggestions
from app.api.endpoints import kb_accelerator
from app.api.endpoints import beta_signup
from app.api.endpoints import scripts
from app.api.endpoints import integrations
api_router = APIRouter()
@@ -58,3 +59,4 @@ api_router.include_router(ai_suggestions.router)
api_router.include_router(kb_accelerator.router)
api_router.include_router(beta_signup.router)
api_router.include_router(scripts.router)
api_router.include_router(integrations.router)

View File

@@ -119,6 +119,16 @@ class Settings(BaseSettings):
"""Check if any AI provider is configured."""
return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None
# ConnectWise PSA Integration
# CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com
# All MSP customers share this single clientId — it identifies ResolutionFlow as the integration
CW_CLIENT_ID: Optional[str] = None
@property
def cw_enabled(self) -> bool:
"""Check if ConnectWise integration is configured."""
return self.CW_CLIENT_ID is not None
# Monitoring
SENTRY_DSN: Optional[str] = None

View File

@@ -36,6 +36,9 @@ from .survey_response import SurveyResponse
from .survey_invite import SurveyInvite
from .kb_import import KBImport, KBImportNode
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
from .psa_connection import PsaConnection
from .psa_post_log import PsaPostLog
from .psa_member_mapping import PsaMemberMapping
__all__ = [
"User",
@@ -86,4 +89,7 @@ __all__ = [
"ScriptCategory",
"ScriptTemplate",
"ScriptGeneration",
"PsaConnection",
"PsaPostLog",
"PsaMemberMapping",
]

View File

@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from app.models.step_category import StepCategory
from app.models.step_library import StepLibrary
from app.models.account_limit_override import AccountLimitOverride
from app.models.psa_connection import PsaConnection
class Account(Base):
@@ -53,3 +54,4 @@ class Account(Base):
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account")
step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account")
limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False)
psa_connection: Mapped[Optional["PsaConnection"]] = relationship("PsaConnection", back_populates="account", uselist=False)

View File

@@ -0,0 +1,48 @@
"""PSA connection model — one per account."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PsaConnection(Base):
__tablename__ = "psa_connections"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
site_url: Mapped[str] = mapped_column(String(255), nullable=False)
company_id: Mapped[str] = mapped_column(String(100), nullable=False)
credentials_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_validated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Relationships
account = relationship("Account", back_populates="psa_connection")

View File

@@ -0,0 +1,47 @@
"""Maps ResolutionFlow users to CW members."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PsaMemberMapping(Base):
__tablename__ = "psa_member_mappings"
__table_args__ = (
UniqueConstraint("psa_connection_id", "user_id", name="uq_psa_member_mapping_connection_user"),
UniqueConstraint("psa_connection_id", "external_member_id", name="uq_psa_member_mapping_connection_member"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
psa_connection_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
external_member_id: Mapped[str] = mapped_column(String(100), nullable=False)
external_member_name: Mapped[str] = mapped_column(String(200), nullable=False)
matched_by: Mapped[str] = mapped_column(String(50), nullable=False) # 'auto_email', 'manual_admin', 'manual_self'
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Relationships
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
user = relationship("User", foreign_keys=[user_id])

View File

@@ -0,0 +1,58 @@
"""Audit trail for notes posted to PSA systems."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PsaPostLog(Base):
__tablename__ = "psa_post_log"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
ticket_id: Mapped[str] = mapped_column(String(100), nullable=False)
note_type: Mapped[str] = mapped_column(String(50), nullable=False)
content_posted: Mapped[str] = mapped_column(Text, nullable=False)
external_note_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
status: Mapped[str] = mapped_column(
String(20), nullable=False
) # 'success' or 'failed'
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status_changed_from: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
status_changed_to: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
posted_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
)
posted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
# Relationships
session = relationship("Session", foreign_keys=[session_id])
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
user = relationship("User", foreign_keys=[posted_by])

View File

@@ -83,6 +83,15 @@ class Session(Base):
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
# PSA ticket link
psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
# Batch tracking (maintenance flows)
batch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), nullable=True, index=True

View File

@@ -15,6 +15,12 @@ from .script_template import (
ScriptTemplateCreate, ScriptTemplateUpdate, ScriptTemplateListItem, ScriptTemplateDetail,
ScriptGenerateRequest, ScriptGenerateResponse, ScriptGenerationRecord,
)
from .psa_connection import (
PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse,
PSATicketSearchResult, PSATicketStatusItem,
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
)
__all__ = [
# User
@@ -39,4 +45,9 @@ __all__ = [
"ScriptCategoryResponse",
"ScriptTemplateCreate", "ScriptTemplateUpdate", "ScriptTemplateListItem", "ScriptTemplateDetail",
"ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord",
# PSA Connection
"PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse",
"PSATicketSearchResult", "PSATicketStatusItem",
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
]

View File

@@ -0,0 +1,138 @@
"""Pydantic schemas for PSA connection management."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class PsaConnectionCreate(BaseModel):
provider: str = Field(default="connectwise", pattern="^(connectwise|autotask)$")
display_name: str = Field(min_length=1, max_length=100)
site_url: str = Field(min_length=1, max_length=255)
company_id: str = Field(min_length=1, max_length=100)
public_key: str = Field(min_length=1)
private_key: str = Field(min_length=1)
# Note: client_id is NOT per-MSP — it's a product-level GUID from settings.CW_CLIENT_ID
class PsaConnectionUpdate(BaseModel):
display_name: str | None = None
site_url: str | None = None
company_id: str | None = None
public_key: str | None = None
private_key: str | None = None
class PsaConnectionResponse(BaseModel):
id: UUID
account_id: UUID
provider: str
display_name: str
site_url: str
company_id: str
is_active: bool
last_validated_at: datetime | None
created_at: datetime
updated_at: datetime
public_key_hint: str
private_key_hint: str
model_config = {"from_attributes": True}
class PsaConnectionTestResponse(BaseModel):
success: bool
message: str
server_version: str | None = None
# ── Ticket search & status schemas ────────────────────────────────
class PSATicketSearchResult(BaseModel):
id: str
summary: str
company_name: str | None = None
board_name: str | None = None
status_name: str | None = None
priority_name: str | None = None
closed: bool = False
class PSATicketStatusItem(BaseModel):
id: int
name: str
is_closed: bool = False
# ── PSA post (note posting) schemas ──────────────────────────────
class PsaPostRequest(BaseModel):
note_type: str = Field(pattern="^(internal_analysis|resolution|description)$")
content: str = Field(min_length=1)
update_status_id: int | None = None
class PsaPostResponse(BaseModel):
id: str
session_id: str
ticket_id: str
note_type: str
status: str
external_note_id: str | None = None
error_message: str | None = None
status_changed_from: str | None = None
status_changed_to: str | None = None
posted_at: str
class PsaPreviewResponse(BaseModel):
content: str
ticket: PSATicketSearchResult
available_statuses: list[PSATicketStatusItem]
character_count: int
previous_posts: int
class PsaPostLogResponse(BaseModel):
id: str
ticket_id: str
note_type: str
status: str
error_message: str | None = None
status_changed_from: str | None = None
status_changed_to: str | None = None
posted_at: str
content_preview: str # first 200 chars
# ── Member mapping schemas ───────────────────────────────────────
class PsaMemberMappingResponse(BaseModel):
id: str
user_id: str
user_email: str
user_name: str
external_member_id: str
external_member_name: str
matched_by: str
class PsaMemberMappingSaveRequest(BaseModel):
user_id: str
external_member_id: str
external_member_name: str
class PsaMemberResponse(BaseModel):
id: str
identifier: str
name: str
email: str | None = None
class AutoMatchResult(BaseModel):
matched: list[PsaMemberMappingResponse]
unmatched_users: int

View File

@@ -94,6 +94,10 @@ class SessionResponse(BaseModel):
batch_id: Optional[UUID] = None
target_label: Optional[str] = None
# PSA ticket link
psa_ticket_id: Optional[str] = None
psa_connection_id: Optional[UUID] = None
class Config:
from_attributes = True
@@ -140,3 +144,28 @@ class SaveAsTreeResponse(BaseModel):
tree_id: UUID
tree_name: str
message: str
# ── PSA ticket link ──────────────────────────────────────────────────
class TicketLinkRequest(BaseModel):
"""Link or unlink a PSA ticket to a session."""
psa_ticket_id: Optional[str] = None # null to unlink
class PSATicketResponse(BaseModel):
"""PSA ticket details returned when linking."""
id: str
summary: str
company_name: Optional[str] = None
board_name: Optional[str] = None
status_name: Optional[str] = None
priority_name: Optional[str] = None
class TicketLinkResponse(BaseModel):
"""Response after linking/unlinking a ticket."""
session_id: str
psa_ticket_id: Optional[str] = None
ticket: Optional[PSATicketResponse] = None

View File

@@ -0,0 +1 @@
"""PSA integration abstraction layer."""

View File

@@ -0,0 +1,68 @@
"""Abstract base class for PSA provider implementations."""
from __future__ import annotations
from abc import ABC, abstractmethod
from .types import (
ConnectionTestResult,
PSATicket,
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
)
class PSAProvider(ABC):
"""Abstract base for PSA integrations (ConnectWise, Autotask, etc.)."""
@abstractmethod
async def test_connection(self) -> ConnectionTestResult:
...
@abstractmethod
async def get_ticket(self, ticket_id: str) -> PSATicket:
...
@abstractmethod
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
...
@abstractmethod
async def post_note(
self,
ticket_id: str,
text: str,
note_type: str,
member_id: str | None = None,
) -> PSANote:
...
@abstractmethod
async def update_ticket_status(
self,
ticket_id: str,
status_id: int,
) -> PSATicket:
...
@abstractmethod
async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]:
...
@abstractmethod
async def list_companies(self, **filters) -> list[PSACompany]:
...
@abstractmethod
async def get_company(self, company_id: str) -> PSACompany:
...
@abstractmethod
async def list_members(self) -> list[PSAMember]:
...
@abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
...

View File

@@ -0,0 +1,38 @@
"""Simple in-memory TTL cache for PSA API responses."""
from __future__ import annotations
import time
from typing import Any
class PSACache:
"""Account-scoped in-memory cache with TTL expiry."""
def __init__(self) -> None:
self._store: dict[str, tuple[Any, float]] = {}
def get(self, key: str) -> Any | None:
entry = self._store.get(key)
if entry is None:
return None
value, expires_at = entry
if time.time() > expires_at:
del self._store[key]
return None
return value
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
self._store[key] = (value, time.time() + ttl_seconds)
def invalidate(self, prefix: str) -> None:
"""Remove all entries matching a key prefix."""
keys_to_remove = [k for k in self._store if k.startswith(prefix)]
for k in keys_to_remove:
del self._store[k]
def clear(self) -> None:
self._store.clear()
# Global singleton — acceptable at current scale (see design doc section 6)
psa_cache = PSACache()

View File

@@ -0,0 +1 @@
"""ConnectWise PSA provider implementation."""

View File

@@ -0,0 +1,288 @@
"""Low-level HTTP client for ConnectWise PSA REST API.
Handles auth headers, base URL resolution (cloud vs on-premise),
pagination, retry with backoff, and error mapping.
"""
from __future__ import annotations
import asyncio
import base64
import ipaddress
import logging
import socket
from typing import Any
from urllib.parse import urlparse
import httpx
from app.services.psa.exceptions import (
PSAAuthError,
PSAConnectionError,
PSANotFoundError,
PSAPermissionError,
PSARateLimitError,
PSAServerError,
PSATimeoutError,
)
logger = logging.getLogger(__name__)
# Pinned CW API version per best-practices/PSA-Versioning.md
CW_API_VERSION = "2025.16"
CW_ACCEPT_HEADER = f"application/vnd.connectwise.com+json; version={CW_API_VERSION}"
# Known CW cloud domains (for SSRF prevention)
CW_ALLOWED_DOMAINS = {
"myconnectwise.net",
"connectwisedev.com",
}
REQUEST_TIMEOUT = 30.0
MAX_RETRIES = 2
MAX_PAGE_SIZE = 1000
def _validate_site_url(site_url: str) -> None:
"""Validate site_url is a known CW domain (SSRF prevention).
Rejects any hostname that is not a recognized ConnectWise domain
and any hostname that resolves to a private/loopback/link-local IP.
"""
# Ensure scheme for parsing
url = site_url if "://" in site_url else f"https://{site_url}"
parsed = urlparse(url)
hostname = parsed.hostname or ""
# Check against allowed domains
if not any(
hostname.endswith(f".{domain}") or hostname == domain
for domain in CW_ALLOWED_DOMAINS
):
raise PSAConnectionError(
f"Invalid ConnectWise site URL: {hostname}. "
"Must be a *.myconnectwise.net or *.connectwisedev.com domain.",
provider="connectwise",
)
# Resolve and check for private IPs
try:
addrs = socket.getaddrinfo(hostname, None)
for _, _, _, _, sockaddr in addrs:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise PSAConnectionError(
f"Site URL resolves to a private/internal address: {sockaddr[0]}",
provider="connectwise",
)
except socket.gaierror:
raise PSAConnectionError(
f"Cannot resolve hostname: {hostname}",
provider="connectwise",
)
class ConnectWiseClient:
"""Async HTTP client for the ConnectWise PSA API.
Auth: Authorization: Basic {base64(companyId+publicKey:privateKey)} + clientId header
Accept: application/vnd.connectwise.com+json; version=2025.16
Base URL: resolved dynamically via /login/companyinfo/{companyId}
Pagination: page/pageSize params, max 1000 per page, while-loop pattern
Retry: respects 429 Retry-After, max 2 retries with exponential backoff for 5xx
Timeout: 30 seconds per request
"""
def __init__(
self,
site_url: str,
company_id: str,
public_key: str,
private_key: str,
client_id: str,
):
self.site_url = site_url.rstrip("/")
self.company_id = company_id
self.client_id = client_id
# Auth: Base64(companyId+publicKey:privateKey)
auth_string = f"{company_id}+{public_key}:{private_key}"
self._auth_b64 = base64.b64encode(auth_string.encode()).decode()
# Base URL resolved lazily on first request
self._base_url: str | None = None
async def _resolve_base_url(self) -> str:
"""Resolve the CW API base URL using /login/companyinfo/{companyId}.
Cloud environments return a versioned codebase (e.g., v2025_3/) requiring
an 'api-' prefix on the hostname. On-premise returns v4_6_release/ with
no prefix needed.
"""
if self._base_url:
return self._base_url
_validate_site_url(self.site_url)
info_url = f"https://{self.site_url}/login/companyinfo/{self.company_id}"
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
try:
resp = await client.get(info_url)
resp.raise_for_status()
except httpx.TimeoutException:
raise PSATimeoutError(
"Timed out resolving CW base URL", provider="connectwise"
)
except httpx.HTTPError as e:
raise PSAConnectionError(
f"Failed to resolve CW base URL: {e}", provider="connectwise"
)
data = resp.json()
codebase = data.get("Codebase", "v4_6_release/")
site_url = data.get("SiteUrl", self.site_url)
# Cloud codebase (e.g., v2025_3/) requires api- prefix
if codebase != "v4_6_release/":
if not site_url.startswith("api-"):
site_url = f"api-{site_url}"
self._base_url = f"https://{site_url}/{codebase}apis/3.0"
logger.info("Resolved CW base URL: %s", self._base_url)
return self._base_url
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Basic {self._auth_b64}",
"clientId": self.client_id,
"Accept": CW_ACCEPT_HEADER,
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: Any = None,
retries: int = MAX_RETRIES,
) -> Any:
"""Make an authenticated request to the CW API with retry and error mapping."""
base_url = await self._resolve_base_url()
url = f"{base_url}/{path.lstrip('/')}"
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
for attempt in range(retries + 1):
try:
resp = await client.request(
method,
url,
headers=self._headers(),
params=params,
json=json_body,
)
except httpx.TimeoutException:
if attempt < retries:
await asyncio.sleep(2 ** attempt)
continue
raise PSATimeoutError(
"ConnectWise request timed out", provider="connectwise"
)
except httpx.ConnectError:
raise PSAConnectionError(
"Cannot reach ConnectWise server", provider="connectwise"
)
# Rate limit — retry with Retry-After backoff
if resp.status_code == 429:
if attempt < retries:
retry_after = int(resp.headers.get("Retry-After", "5"))
await asyncio.sleep(retry_after)
continue
raise PSARateLimitError(
"ConnectWise rate limit exceeded",
retry_after_seconds=int(
resp.headers.get("Retry-After", "60")
),
provider="connectwise",
)
# Map error status codes to typed exceptions
if resp.status_code == 401:
raise PSAAuthError(
"Invalid credentials. Check your API keys.",
provider="connectwise",
)
if resp.status_code == 403:
raise PSAPermissionError(
"Insufficient permissions. Check the API member's security role.",
provider="connectwise",
)
if resp.status_code == 404:
raise PSANotFoundError(
"Resource not found.", provider="connectwise"
)
if resp.status_code >= 500:
if attempt < retries:
await asyncio.sleep(2 ** attempt)
continue
raise PSAServerError(
"ConnectWise is experiencing issues. Try again.",
provider="connectwise",
)
resp.raise_for_status()
if resp.status_code == 204:
return None
return resp.json()
# Should not reach here, but satisfy type checker
raise PSAConnectionError(
"Request failed after all retries", provider="connectwise"
)
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
"""GET request to CW API."""
return await self._request("GET", path, params=params)
async def post(self, path: str, json_body: Any = None) -> Any:
"""POST request to CW API."""
return await self._request("POST", path, json_body=json_body)
async def patch(self, path: str, json_body: Any = None) -> Any:
"""PATCH request to CW API (JSON Patch array format).
CW uses JSON Patch syntax: [{"op": "replace", "path": "field", "value": ...}]
"""
return await self._request("PATCH", path, json_body=json_body)
async def delete(self, path: str) -> Any:
"""DELETE request to CW API."""
return await self._request("DELETE", path)
async def get_paginated(
self,
path: str,
params: dict[str, Any] | None = None,
max_pages: int = 10,
) -> list[Any]:
"""Fetch all pages of a paginated CW endpoint.
Uses navigable pagination with page/pageSize params.
Stops when a page returns fewer results than pageSize or max_pages is reached.
"""
params = dict(params or {})
params.setdefault("pageSize", MAX_PAGE_SIZE)
all_results: list[Any] = []
for page in range(1, max_pages + 1):
params["page"] = page
results = await self.get(path, params=params)
if not results:
break
all_results.extend(results)
if len(results) < params["pageSize"]:
break
return all_results

View File

@@ -0,0 +1,283 @@
"""ConnectWise implementation of PSAProvider."""
from __future__ import annotations
from app.services.psa.base import PSAProvider
from app.services.psa.cache import psa_cache
from app.services.psa.types import (
ConnectionTestResult,
PSATicket,
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
)
from .client import ConnectWiseClient
class ConnectWiseProvider(PSAProvider):
"""ConnectWise PSA provider implementation."""
def __init__(self, client: ConnectWiseClient):
self.client = client
async def test_connection(self) -> ConnectionTestResult:
"""Test the CW connection by fetching system info."""
try:
info = await self.client.get("/system/info")
return ConnectionTestResult(
success=True,
message="Connected successfully.",
server_version=info.get("version", None),
)
except Exception as e:
return ConnectionTestResult(
success=False,
message=str(e),
server_version=None,
)
# ── Tickets ───────────────────────────────────────────────────────
async def get_ticket(self, ticket_id: str) -> PSATicket:
"""Fetch a single ticket by ID from ConnectWise."""
data = await self.client.get(
f"/service/tickets/{ticket_id}",
params={"fields": "id,summary,company,board,status,priority,closedFlag"},
)
return self._map_ticket(data)
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
"""Search CW tickets by summary. Supports board_id and status_id filters."""
params: dict = {
"fields": "id,summary,company,board,status,priority,closedFlag",
"orderBy": "id desc",
"pageSize": 25,
}
# Build CW condition query
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 conditions:
params["conditions"] = " and ".join(conditions)
data = await self.client.get("/service/tickets", params=params)
return [
self._map_ticket(t)
for t in (data if isinstance(data, list) else [])
]
async def get_ticket_configurations(
self, ticket_id: str
) -> list[PSAConfiguration]:
"""Get configurations (assets) attached to a ticket."""
data = await self.client.get(
f"/service/tickets/{ticket_id}/configurations",
params={"fields": "id,deviceIdentifier,type,company"},
)
return [
PSAConfiguration(
id=str(c["id"]),
name=c.get("deviceIdentifier", ""),
type=c.get("type", {}).get("name") if c.get("type") else None,
company_name=c.get("company", {}).get("name") if c.get("company") else None,
)
for c in (data if isinstance(data, list) else [])
]
# ── Board statuses (cached) ───────────────────────────────────────
async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]:
"""Get available statuses for a CW service board (cached 1 hour)."""
cache_key = f"board_statuses:{board_id}"
cached = psa_cache.get(cache_key)
if cached is not None:
return cached
data = await self.client.get(
f"/service/boards/{board_id}/statuses",
params={"fields": "id,name,closedStatus", "pageSize": 100},
)
result = [
PSAStatus(
id=s["id"],
name=s["name"],
is_closed=s.get("closedStatus", False),
)
for s in (data if isinstance(data, list) else [])
]
psa_cache.set(cache_key, result, ttl_seconds=3600)
return result
# ── Companies ─────────────────────────────────────────────────────
async def list_companies(self, **filters) -> list[PSACompany]:
"""List companies from CW, optionally filtered by status."""
params: dict = {
"fields": "id,name,status",
"pageSize": 100,
"orderBy": "name asc",
}
conditions: list[str] = []
if filters.get("status"):
conditions.append(f"status/name = '{filters['status']}'")
if conditions:
params["conditions"] = " and ".join(conditions)
data = await self.client.get("/company/companies", params=params)
return [
PSACompany(
id=str(c["id"]),
name=c.get("name", ""),
status=c.get("status", {}).get("name") if c.get("status") else None,
)
for c in (data if isinstance(data, list) else [])
]
async def get_company(self, company_id: str) -> PSACompany:
"""Fetch a single company by ID."""
data = await self.client.get(
f"/company/companies/{company_id}",
params={"fields": "id,name,status"},
)
return PSACompany(
id=str(data["id"]),
name=data.get("name", ""),
status=data.get("status", {}).get("name") if data.get("status") else None,
)
# ── Notes & status updates ───────────────────────────────────────
async def post_note(
self,
ticket_id: str,
text: str,
note_type: str,
member_id: str | None = None,
) -> PSANote:
"""Post a note to a CW ticket.
Maps ResolutionFlow note types to CW flag fields:
- internal_analysis → internalAnalysisFlag (internal only)
- resolution → resolutionFlag (internal, triggers notifications)
- description → detailDescriptionFlag (external, triggers notifications)
"""
from app.services.psa.types import NoteType
flags = {
NoteType.INTERNAL_ANALYSIS: {
"internalAnalysisFlag": True,
"resolutionFlag": False,
"detailDescriptionFlag": False,
"internalFlag": True,
"processNotifications": False,
},
NoteType.RESOLUTION: {
"internalAnalysisFlag": False,
"resolutionFlag": True,
"detailDescriptionFlag": False,
"internalFlag": True,
"processNotifications": True,
},
NoteType.DESCRIPTION: {
"internalAnalysisFlag": False,
"resolutionFlag": False,
"detailDescriptionFlag": True,
"internalFlag": False,
"processNotifications": True,
},
}
note_flags = flags.get(note_type, flags[NoteType.INTERNAL_ANALYSIS])
# NOTE: CW Developer Guide states \n is "Not Supported" in JSON bodies
# and may be collapsed to a single space. CW does support markdown in ticket
# notes (see PSA-Markdown.md). This needs sandbox testing — if newlines are
# lost, consider using double-space line breaks or HTML <br> tags instead.
body: dict = {
"text": text,
**note_flags,
}
if member_id:
body["member"] = {"id": int(member_id)}
data = await self.client.post(
f"/service/tickets/{ticket_id}/notes", json_body=body
)
return PSANote(
id=str(data.get("id", "")),
text=data.get("text", ""),
note_type=note_type,
created_at=data.get("dateCreated"),
)
async def update_ticket_status(
self, ticket_id: str, status_id: int
) -> PSATicket:
"""Update a CW ticket's status using JSON Patch format."""
patch_body = [
{"op": "replace", "path": "status", "value": {"id": status_id}}
]
data = await self.client.patch(
f"/service/tickets/{ticket_id}", json_body=patch_body
)
return self._map_ticket(data)
async def list_members(self) -> list[PSAMember]:
"""List CW system members (cached 15 minutes)."""
cache_key = "members:all"
cached = psa_cache.get(cache_key)
if cached is not None:
return cached
data = await self.client.get_paginated(
"/system/members",
params={
"fields": "id,identifier,firstName,lastName,officeEmail",
"conditions": "inactiveFlag = false",
"pageSize": 1000,
},
)
result = [
PSAMember(
id=str(m["id"]),
identifier=m.get("identifier", ""),
name=f"{m.get('firstName', '')} {m.get('lastName', '')}".strip(),
email=m.get("officeEmail"),
)
for m in data
]
psa_cache.set(cache_key, result, ttl_seconds=900)
return result
# ── Private helpers ───────────────────────────────────────────────
@staticmethod
def _map_ticket(data: dict) -> PSATicket:
"""Map a CW ticket JSON dict to a PSATicket."""
return PSATicket(
id=str(data["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"),
closed=data.get("closedFlag", False),
)

View File

@@ -0,0 +1,53 @@
"""Fernet-based credential encryption for PSA connections.
Uses the application SECRET_KEY to derive a Fernet encryption key via HKDF.
Credentials are stored as a single encrypted JSON blob.
"""
from __future__ import annotations
import json
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from app.core.config import settings
def _get_fernet() -> Fernet:
"""Derive a Fernet key from the application SECRET_KEY."""
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=b"resolutionflow-psa-credentials",
info=b"psa-credential-encryption",
)
key = hkdf.derive(settings.SECRET_KEY.encode())
fernet_key = base64.urlsafe_b64encode(key)
return Fernet(fernet_key)
def encrypt_credentials(credentials: dict) -> str:
"""Encrypt a credentials dict to a Fernet token string."""
f = _get_fernet()
plaintext = json.dumps(credentials).encode()
return f.encrypt(plaintext).decode()
def decrypt_credentials(encrypted: str) -> dict:
"""Decrypt a Fernet token string back to a credentials dict."""
f = _get_fernet()
plaintext = f.decrypt(encrypted.encode())
return json.loads(plaintext)
def mask_credential(value: str | None, visible_suffix: int = 4) -> str:
"""Return a masked version of a credential for display.
e.g., 'abcdefghij' -> '......ghij'
"""
if not value:
return "\u2022\u2022\u2022\u2022\u2022\u2022"
if len(value) <= visible_suffix:
return "\u2022\u2022\u2022\u2022\u2022\u2022" + value
return "\u2022\u2022\u2022\u2022\u2022\u2022" + value[-visible_suffix:]

View File

@@ -0,0 +1,45 @@
"""Typed exceptions for PSA integration errors."""
class PSAError(Exception):
"""Base exception for all PSA integration errors."""
def __init__(self, message: str, provider: str = "unknown"):
self.provider = provider
super().__init__(message)
class PSAAuthError(PSAError):
"""Invalid or expired credentials."""
pass
class PSAPermissionError(PSAError):
"""Insufficient permissions on the PSA side."""
pass
class PSANotFoundError(PSAError):
"""Requested resource (ticket, company, etc.) not found."""
pass
class PSARateLimitError(PSAError):
"""Rate limit exceeded. retry_after_seconds may be set."""
def __init__(self, message: str, retry_after_seconds: int | None = None, provider: str = "unknown"):
self.retry_after_seconds = retry_after_seconds
super().__init__(message, provider)
class PSAServerError(PSAError):
"""Remote PSA server error (5xx)."""
pass
class PSATimeoutError(PSAError):
"""Request to PSA timed out."""
pass
class PSAConnectionError(PSAError):
"""Cannot reach the PSA server."""
pass

View File

@@ -0,0 +1,51 @@
"""Factory for instantiating PSA providers from stored connection data."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.psa_connection import PsaConnection
from app.services.psa.base import PSAProvider
from app.core.config import settings
from app.services.psa.encryption import decrypt_credentials
from app.services.psa.exceptions import PSAConnectionError
async def get_provider_for_account(
account_id: UUID, db: AsyncSession
) -> PSAProvider:
"""Look up account's PSA connection, decrypt credentials, instantiate provider."""
result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == account_id,
PsaConnection.is_active.is_(True),
)
)
connection = result.scalar_one_or_none()
if not connection:
raise PSAConnectionError(
"No active PSA connection configured for this account.",
provider="unknown",
)
if connection.provider == "connectwise":
from app.services.psa.connectwise.client import ConnectWiseClient
from app.services.psa.connectwise.provider import ConnectWiseProvider
creds = decrypt_credentials(connection.credentials_encrypted)
client = ConnectWiseClient(
site_url=connection.site_url,
company_id=connection.company_id,
public_key=creds["public_key"],
private_key=creds["private_key"],
client_id=settings.CW_CLIENT_ID or "",
)
return ConnectWiseProvider(client)
raise PSAConnectionError(
f"Unsupported PSA provider: {connection.provider}",
provider=connection.provider,
)

View File

@@ -0,0 +1,63 @@
"""Provider-agnostic PSA data types."""
from __future__ import annotations
from pydantic import BaseModel
class ConnectionTestResult(BaseModel):
success: bool
message: str
server_version: str | None = None
class PSATicket(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
class PSANote(BaseModel):
id: str
text: str
note_type: str
created_at: str | None = None
class PSAStatus(BaseModel):
id: int
name: str
is_closed: bool = False
class PSACompany(BaseModel):
id: str
name: str
status: str | None = None
class PSAMember(BaseModel):
id: str
identifier: str # CW login username
name: str
email: str | None = None
class PSAConfiguration(BaseModel):
id: str
name: str
type: str | None = None
company_name: str | None = None
class NoteType:
INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution"
DESCRIPTION = "description"

View File

@@ -0,0 +1,59 @@
"""Tests for PSA connection endpoints — routing and RBAC only.
We cannot fully test create/update/test endpoints in CI because they
call the ConnectWise API. These tests verify routing and authorization.
"""
import pytest
from sqlalchemy import select, update
from app.models.user import User
@pytest.mark.asyncio
async def test_get_connection_empty(client, admin_auth_headers):
"""GET returns null when no connection exists."""
response = await client.get(
"/api/v1/integrations/psa/connections",
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json() is None
@pytest.mark.asyncio
async def test_create_connection_requires_owner(client, test_user, auth_headers, test_db):
"""Engineer (non-owner) should get 403 on create."""
# Downgrade the test user from owner to engineer so require_account_owner rejects
user_id = test_user["user_data"]["id"]
await test_db.execute(
update(User).where(User.id == user_id).values(account_role="engineer")
)
await test_db.commit()
payload = {
"provider": "connectwise",
"display_name": "Test CW",
"site_url": "https://na.myconnectwise.net",
"company_id": "testmsp",
"public_key": "pub123",
"private_key": "priv456",
"client_id": "client789",
}
response = await client.post(
"/api/v1/integrations/psa/connections",
json=payload,
headers=auth_headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_delete_nonexistent_returns_404(client, admin_auth_headers):
"""DELETE with a nonexistent ID returns 404."""
import uuid
fake_id = uuid.uuid4()
response = await client.delete(
f"/api/v1/integrations/psa/connections/{fake_id}",
headers=admin_auth_headers,
)
assert response.status_code == 404

View File

@@ -0,0 +1,44 @@
"""Tests for PSA credential encryption/decryption."""
import pytest
from app.services.psa.encryption import encrypt_credentials, decrypt_credentials
class TestCredentialEncryption:
def test_round_trip(self):
"""Encrypt then decrypt returns original credentials."""
creds = {
"public_key": "abc123",
"private_key": "secret456",
"client_id": "my-client-id",
}
encrypted = encrypt_credentials(creds)
# Encrypted should be a non-empty string, different from input
assert isinstance(encrypted, str)
assert len(encrypted) > 0
assert "secret456" not in encrypted
decrypted = decrypt_credentials(encrypted)
assert decrypted == creds
def test_different_inputs_produce_different_outputs(self):
creds1 = {"public_key": "key1", "private_key": "priv1", "client_id": "cid1"}
creds2 = {"public_key": "key2", "private_key": "priv2", "client_id": "cid2"}
enc1 = encrypt_credentials(creds1)
enc2 = encrypt_credentials(creds2)
assert enc1 != enc2
def test_tampered_ciphertext_raises(self):
creds = {"public_key": "k", "private_key": "p", "client_id": "c"}
encrypted = encrypt_credentials(creds)
tampered = encrypted[:-5] + "XXXXX"
with pytest.raises(Exception):
decrypt_credentials(tampered)
def test_mask_private_key(self):
from app.services.psa.encryption import mask_credential
assert mask_credential("abcdefghij") == "\u2022\u2022\u2022\u2022\u2022\u2022ghij"
assert mask_credential("abc") == "\u2022\u2022\u2022\u2022\u2022\u2022abc"
assert mask_credential("") == "\u2022\u2022\u2022\u2022\u2022\u2022"
assert mask_credential(None) == "\u2022\u2022\u2022\u2022\u2022\u2022"

303215
docs/connectwise/All.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,267 @@
# ConnectWise PSA API — Integration Quick Reference
## For ResolutionFlow Development (Claude Code)
> **Source:** ConnectWise PSA OpenAPI Spec v2025.16
> **Full extracted spec:** `docs/connectwise/connectwise-psa-resolutionflow-reference.json`
> **Full original spec:** `docs/connectwise/All.json` (7.6MB, 1838 endpoints, 842 schemas)
---
## Authentication
- **Method:** API Key (Public/Private key pair per API Member)
- **Headers required on every request:**
- `Authorization: Basic {base64(companyId+publicKey:privateKey)}`
- `clientId: {your_connectwise_client_id}` (assigned via developer program)
- **Accept header:** `application/vnd.connectwise.com+json; version=2025.16`
- **Base URL pattern:** `https://{site}/v4_6_release/apis/3.0`
- **Date format:** ISO 8601 `yyyy-MM-ddTHH:mm:ssZ`
- **SSL required** on production servers
## Pagination & Query
- Default page size: 25, max: 1000
- Query params: `conditions`, `orderBy`, `fields`, `page`, `pageSize`, `childConditions`, `customFieldConditions`
- Condition syntax: `fieldName="value"`, supports `AND`, `OR`, `like`, `contains`, `>`, `<`, `!=`
- No documented hard rate limits (design respectfully)
---
## TIER 1 — Core Integration
### Service Tickets (`/service/tickets`)
**The central entity. 120 fields. 33 endpoints.**
Key endpoints:
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/service/tickets` | List tickets (with conditions filter) |
| POST | `/service/tickets` | Create a ticket |
| GET | `/service/tickets/{id}` | Get single ticket |
| PATCH | `/service/tickets/{id}` | Update ticket fields |
| GET | `/service/tickets/{parentId}/notes` | List ticket notes |
| POST | `/service/tickets/{parentId}/notes` | **Add note to ticket** ⭐ |
| GET | `/service/tickets/{parentId}/configurations` | Get attached devices |
| POST | `/service/tickets/{parentId}/configurations` | Attach a device |
| GET | `/service/tickets/{parentId}/tasks` | Get ticket tasks |
| POST | `/service/tickets/{parentId}/tasks` | Add task to ticket |
| GET | `/service/tickets/{parentId}/allNotes` | Get ALL note types |
| POST | `/service/tickets/search` | Advanced search |
| GET | `/service/tickets/calculateSla` | Get SLA times |
| POST | `/service/tickets/{parentId}/merge` | Merge tickets |
| POST | `/service/tickets/{id}/copy` | Copy a ticket |
| POST | `/service/tickets/{parentId}/convert` | Convert to project |
Key Ticket fields for ResolutionFlow:
| Field | Type | Notes |
|-------|------|-------|
| `id` | integer | |
| `summary` | string | Max length: 100; |
| `board` | BoardReference | |
| `status` | ServiceStatusReference | |
| `company` | CompanyReference | |
| `contact` | ContactReference | |
| `contactName` | string | Max length: 62; |
| `contactEmailAddress` | string | Max length: 250; |
| `site` | SiteReference | |
| `type` | ServiceTypeReference | |
| `subType` | ServiceSubTypeReference | |
| `item` | ServiceItemReference | |
| `team` | ServiceTeamReference | |
| `owner` | MemberReference | |
| `priority` | PriorityReference | |
| `severity` | string | Required On Updates; |
| `impact` | string | Required On Updates; |
| `source` | ServiceSourceReference | |
| `sla` | SLAReference | |
| `slaStatus` | string | |
| `isInSla` | boolean | |
| `agreement` | AgreementReference | |
| `initialDescription` | string | Only available for POST, will not be returned in the response. |
| `initialInternalAnalysis` | string | Only available for POST, will not be returned in the response. |
| `initialResolution` | string | Only available for POST, will not be returned in the response. |
| `closedFlag` | boolean | |
| `closedDate` | string | |
| `closedBy` | string | |
| `dateResolved` | string | |
| `dateResponded` | string | |
| `actualHours` | number | |
| `budgetHours` | number | |
| `resources` | string | |
| `parentTicketId` | integer | |
| `externalXRef` | string | Max length: 100; |
| `knowledgeBaseLinkId` | integer | |
| `customFields` | array | |
| `processNotifications` | boolean | Can be set to false to skip notification processing when adding or updating a... |
| `skipCallback` | boolean | |
| `recordType` | string | |
| `respondMinutes` | integer | To obtain the current SLA times for an active ticket, please use the /service... |
| `resPlanMinutes` | integer | To obtain the current SLA times for an active ticket, please use the /service... |
| `resolveMinutes` | integer | To obtain the current SLA times for an active ticket, please use the /service... |
### ServiceNote / TicketNote Schema
**The schema for notes added to tickets. This is where session documentation lands.**
| Field | Type | Purpose |
|-------|------|---------|
| `id` | integer | Note ID |
| `ticketId` | integer | Parent ticket |
| `text` | string | **Note content — session documentation goes here** |
| `detailDescriptionFlag` | boolean | Mark as "Description" note |
| `internalAnalysisFlag` | boolean | Mark as "Internal Analysis" note ⭐ |
| `resolutionFlag` | boolean | Mark as "Resolution" note ⭐ |
| `issueFlag` | boolean | Mark as issue note |
| `internalFlag` | boolean | Internal-only (not visible to customer) |
| `externalFlag` | boolean | Visible to customer |
| `member` | MemberReference | Who wrote the note |
| `contact` | ContactReference | Contact associated |
| `processNotifications` | boolean | Trigger notification workflows |
| `dateCreated` | string | When created |
| `createdBy` | string | Creator |
| `sentimentScore` | number | AI sentiment (ServiceNote only) |
**ResolutionFlow mapping:** Post session documentation as an **internal analysis note** (`internalAnalysisFlag: true, internalFlag: true`), and optionally post a resolution summary (`resolutionFlag: true`) when the session resolves the issue.
### Companies (`/company/companies`)
**72 fields. Client/customer context for sessions.**
Key fields: `id`, `identifier`, `name`, `status`, `addressLine1`, `city`, `state`, `zip`, `phoneNumber`, `website`, `territory`, `market`, `defaultContact`, `parentCompany`, `customFields`
Key endpoints:
- `GET /company/companies` — List/search companies
- `GET /company/companies/{id}` — Get company detail
- `GET /company/companies/{parentId}/sites` — Get company sites
- `GET /company/companies/{parentId}/notes` — Get company notes
- `GET /company/companies/{parentId}/groups` — Get company groups
- `GET /company/companies/{parentId}/teams` — Get company teams
### Contacts (`/company/contacts`)
**62 fields. The person reporting the issue.**
Key fields: `id`, `firstName`, `lastName`, `company`, `site`, `title`, `department`, `inactiveFlag`, `defaultPhoneType`, `defaultBillingFlag`, `communicationItems`
### Configurations / Assets (`/company/configurations`)
**58 fields. Devices and assets — critical MSP context.**
Key fields: `id`, `name`, `type`, `status`, `company`, `contact`, `site`, `deviceIdentifier`, `serialNumber`, `modelNumber`, `tagNumber`, `purchaseDate`, `installationDate`, `warrantyExpirationDate`, `vendorNotes`, `osType`, `osInfo`, `cpuSpeed`, `ram`, `lastBackupDate`, `ipAddress`, `macAddress`, `lastLoginName`, `customFields`
### Boards & Statuses (`/service/boards`)
**65-field Board model. 23-field BoardStatus model. 63 endpoints.**
Boards define service desk structure. Each board has statuses, types, subtypes, items, teams, notifications.
Key endpoints:
- `GET /service/boards` — List all boards
- `GET /service/boards/{id}` — Get board detail
- `GET /service/boards/{parentId}/statuses` — Get board statuses
- `GET /service/boards/{parentId}/types` — Get ticket types for board
- `GET /service/boards/{parentId}/subtypes` — Get subtypes
- `GET /service/boards/{parentId}/items` — Get items
- `GET /service/boards/{parentId}/teams` — Get board teams
### Callbacks / Webhooks (`/system/callbacks`)
**Real-time event notifications.**
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/system/callbacks` | List registered callbacks |
| POST | `/system/callbacks` | **Register a new callback** |
| GET | `/system/callbacks/{id}` | Get callback detail |
| PATCH | `/system/callbacks/{id}` | Update callback |
| DELETE | `/system/callbacks/{id}` | Remove callback |
CallbackEntry fields: `id`, `description`, `url`, `objectId`, `type`, `level`, `memberId`, `payloadVersion`, `inactiveFlag`
**ResolutionFlow usage:** Register callbacks for ticket creation/update events to suggest relevant Flows in real-time.
---
## TIER 2 — High Value Add-ons
### Knowledge Base (`/service/knowledgeBaseArticles`)
**12 fields. Maps directly to Flows.**
| Field | Type | Purpose |
|-------|------|---------|
| `id` | integer | Article ID |
| `title` | string | Article title |
| `issue` | string | **Problem description** |
| `resolution` | string | **Solution** |
| `board` | BoardReference | Associated board |
| `categoryId` | integer | KB category |
| `subCategoryId` | integer | KB subcategory |
**ResolutionFlow mapping:** Completed sessions → auto-generate KB articles. Existing KB articles → inform FlowPilot AI suggestions.
### Time Entries (`/time/entries`)
**62 fields. Auto-log time from session duration.**
Key fields: `id`, `company`, `chargeToId`, `chargeToType`, `member`, `workType`, `workRole`, `timeStart`, `timeEnd`, `actualHours`, `notes`, `internalNotes`
### Documents (`/system/documents`)
**Attach session exports to tickets.**
- `POST /system/documents` — Upload document (multipart form data with `recordType=Ticket&recordId={id}`)
- `GET /system/documents/{id}/download` — Download document
### Members (`/system/members`)
**126 fields. Map CW members to ResolutionFlow users.**
Key fields: `id`, `identifier`, `firstName`, `lastName`, `emailAddress`, `photo`, `title`, `securityRole`, `defaultLocation`, `defaultDepartment`
### SLAs (`/service/SLAs`)
**23 fields. Pull SLA context into sessions.**
Fields include response/resolution hours and percentages by impact/urgency matrix.
### Priorities (`/service/priorities`)
Fields: `id`, `name`, `color`, `sortOrder`, `defaultFlag`, `level`
---
## TIER 3 — Future Expansion
### Projects (`/project/projects`)
**For larger MSP engagements. 11 endpoints.**
Key endpoints: CRUD on projects, plus `/project/projects/{parentId}/contacts`, `/project/projects/{parentId}/notes`, `/project/projects/{parentId}/teamMembers`
### Project Tickets (`/project/tickets`)
**80 fields. 24 endpoints. Separate from service tickets but similar structure.**
Includes own notes system at `/project/ticketNote`
### Workflows (`/system/workflows`)
**46 endpoints. ConnectWise's internal automation engine.**
Could trigger ResolutionFlow sessions automatically based on ticket events.
### Activities (`/sales/activities`)
**Track follow-ups post-session.**
### Agreements (`/finance/agreements`)
**44 endpoints. Billing/SLA context per client.**
---
## Integration Architecture Notes
### Session → Ticket Note (Primary Flow)
1. Engineer opens session, optionally links to CW ticket ID
2. Session documentation auto-generates during troubleshooting
3. On session complete: POST to `/service/tickets/{ticketId}/notes` with `internalAnalysisFlag: true`
4. Optionally POST resolution note with `resolutionFlag: true`
5. Optionally update ticket status via PATCH `/service/tickets/{id}`
### Ticket Context → Session (Reverse Flow)
1. Engineer enters CW ticket ID or session is launched from CW callback
2. GET `/service/tickets/{id}` for ticket details
3. GET `/service/tickets/{id}/configurations` for attached devices
4. GET `/company/companies/{companyId}` for client context
5. Feed all context to FlowPilot AI for informed troubleshooting
### Callback-Driven Flow Suggestions
1. Register callback via POST `/system/callbacks`
2. Receive ticket creation events at ResolutionFlow webhook endpoint
3. Analyze ticket summary/type/board
4. Suggest relevant Flows to the assigned engineer

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
1. Last updated
Mar 31, 2023
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/1350/pdf/Bundled%2bRequests.pdf "Export page as a PDF")
#### Purpose
The Bundle endpoint is so that the end users can post multiple distinct requests at the same time. An example is if you need data from a number of endpoints for a single screen, instead of making each individual call you would bundle them together and make one request. This will save time and bandwidth.
## Request
When sending a bundled request to the server, you will send an array of the same objects you would send in a singular request.
|`01`|`{`|
|---|---|
|`02`|`"requests"``: [`|
|---|---|
|`03`|`{`|
|---|---|
|`04`|`"Version"``:` `"2019.3"``,`|
|---|---|
|`05`|`"SequenceNumber"``:` `1``,`|
|---|---|
|`06`|`"ResourceType"``:` `"ticket"``,`|
|---|---|
|`07`|`"ApiRequest"``: {`|
|---|---|
|`08`|`"Filters"``: {`|
|---|---|
|`09`|`"conditions"``:` `"summary like '%a%' and status/name like '%new%'and board/name='Help Desk'"`|
|---|---|
|`10`|`},`|
|---|---|
|`11`|`"Page"``: {`|
|---|---|
|`12`|`"page"``:` `1``,`|
|---|---|
|`13`|`"pageSize"``:` `1000`|
|---|---|
|`14`|`}`|
|---|---|
|`15`|`}`|
|---|---|
|`16`|`},`|
|---|---|
|`17`|`{`|
|---|---|
|`18`|`"Version"``:` `"2019.2"``,`|
|---|---|
|`19`|`"SequenceNumber"``:` `1``,`|
|---|---|
|`20`|`"ResourceType"``:` `"Company"``,`|
|---|---|
|`21`|`"ApiRequest"``: {`|
|---|---|
|`22`|`"Filters"``: {`|
|---|---|
|`23`|`"conditions"``:` `"companyName = 'Connectwise'"``,`|
|---|---|
|`24`|`"childConditions"``:` `""``,`|
|---|---|
|`25`|`"customFieldConditions"``:` `""`|
|---|---|
|`26`|`},`|
|---|---|
|`27`|`"Page"``: {`|
|---|---|
|`28`|`"pageSize"``:` `25``,`|
|---|---|
|`29`|`"pageId"``:` `2`|
|---|---|
|`30`|`},`|
|---|---|
|`31`|`"Id"``:` `1``,`|
|---|---|
|`32`|`"ParentId"``:` `2``,`|
|---|---|
|`33`|`"GrandParentId"``:` `3`|
|---|---|
|`34`|`}`|
|---|---|
|`35`|`},`|
|---|---|
|`36`|`{`|
|---|---|
|`37`|`"Version"``:` `"2019.2"``,`|
|---|---|
|`38`|`"SequenceNumber"``:` `2``,`|
|---|---|
|`39`|`"ResourceName"``:` `"Member"``,`|
|---|---|
|`40`|`"ApiRequest"``: {`|
|---|---|
|`41`|`"Id"``:` `1``,`|
|---|---|
|`42`|`"ParentId"``:` `2``,`|
|---|---|
|`43`|`"GrandParentId"``:` `3``,`|
|---|---|
|`44`|`"Filters"``: {`|
|---|---|
|`45`|`"conditions"``:` `"memberName = 'John Smith'"``,`|
|---|---|
|`46`|`"childConditions"``:` `""``,`|
|---|---|
|`47`|`"customFieldConditions"``:` `""`|
|---|---|
|`48`|`},`|
|---|---|
|`49`|`"Page"``: {`|
|---|---|
|`50`|`"page"``:` `1``,`|
|---|---|
|`51`|`"pageSize"``:` `25`|
|---|---|
|`52`|`}`|
|---|---|
|`53`|`}`|
|---|---|
|`54`|`}`|
|---|---|
|`55`|`]`|
|---|---|
|`56`|`}`|
|---|---|
### Example: Line Items from PO
This example is a GET on Line Items for purchase order 1.  Note the ParentId specifies the purchase order recId.
|`01`|`{`|
|---|---|
|`02`|`"requests"``:`|
|---|---|
|`03`|`[`|
|---|---|
|`04`|`{`|
|---|---|
|`05`|`"Version"``:``"2019.5"``,`|
|---|---|
|`06`|`"SequenceNumber"``:``1``,`|
|---|---|
|`07`|`"ResourceType"``:``"purchaseorderlineitem"``,`|
|---|---|
|`08`|`"ApiRequest"``:{`|
|---|---|
|`09`|`"Filters"``:`|
|---|---|
|`10`|`{`|
|---|---|
|`11`|`"conditions"``:``""``,`|
|---|---|
|`12`|`"childConditions"` `:` `""``,`|
|---|---|
|`13`|`"customFieldConditions"``:` `""`|
|---|---|
|`14`|`},`|
|---|---|
|`15`|
|---|
|`16`|`"Page"``: {`|
|---|---|
|`17`|`"page"` `:` `1``,`|
|---|---|
|`18`|`"pageSize"``:` `1000`|
|---|---|
|`19`|`},`|
|---|---|
|`20`|`"parentId"``:``1`|
|---|---|
|`21`|`}`|
|---|---|
|`22`|`}`|
|---|---|
|`23`|`]`|
|---|---|
|`24`|`}`|
|---|---|
## Response
The response object that is returned is slightly different from what they normally get back from the singular endpoints. This is because we need to track the success status of each object individually. Because of this, there will be a wrapper class around each returned object that will have status information.
Furthermore, since each record may have different statuses, we will respond with different status codes depending on what the outcomes are.
The wrapper will have 5 properties:
- success: This property is a boolean that indicates if the response was a success or an error occurred
- sequenceNumber: This is the sequence number passed in with the array. This is so you can map the returned object back to the original sent-in object so you can sync ids and all other information
- statusCode: This will be filled in with what the status code would have been if the user had called the individual endpoint (e.g. 200, 400, 403)
- error: If success is false, this will be populated with the error response that you would have received if you called the individual endpoint; Else this will be null and not returned.
- data: If success is true, this will be populated with the returned object as if the called the individual endpoint; Else this will be null and not returned
|`01`|`{`|
|---|---|
|`02`|`"results"``: [`|
|---|---|
|`03`|`{`|
|---|---|
|`04`|`"sequenceNumber"``:` `1``,`|
|---|---|
|`05`|`"resourceType"``:` `"Company"``,`|
|---|---|
|`06`|`"entities"``: [`|
|---|---|
|`07`|`{`|
|---|---|
|`08`|`"record"``:` `"Company1data"`|
|---|---|
|`09`|`},`|
|---|---|
|`10`|`{`|
|---|---|
|`11`|`"record2"``:` `"Company2data"`|
|---|---|
|`12`|`}`|
|---|---|
|`13`|`],`|
|---|---|
|`14`|`"count"``:` `2``,`|
|---|---|
|`15`|`"success"``:` `true``,`|
|---|---|
|`16`|`"statusCode"``:` `200`|
|---|---|
|`17`|`},`|
|---|---|
|`18`|`{`|
|---|---|
|`19`|`"sequenceNumber"``:` `2``,`|
|---|---|
|`20`|`"resouceType"``:` `"ServiceNote"``,`|
|---|---|
|`21`|`"count"``:` `0``,`|
|---|---|
|`22`|`"success"``:` `false``,`|
|---|---|
|`23`|`"statusCode"``:` `404``,`|
|---|---|
|`24`|`"error"``: {`|
|---|---|
|`25`|`"code"``:` `"NotFound"``,`|
|---|---|
|`26`|`"message"``:` `"ServiceNote123notfound"`|
|---|---|
|`27`|`}`|
|---|---|
|`28`|`}`|
|---|---|
|`29`|`],`|
|---|---|
|`30`|`"_info"``: {`|
|---|---|
|`31`|`"failure"``:` `"1"``,`|
|---|---|
|`32`|`"success"``:` `"1"``,`|
|---|---|
|`33`|`"total"``:` `"2"`|
|---|---|
|`34`|`}`|
|---|---|
|`35`|`}`|
|---|---|

View File

@@ -0,0 +1,360 @@
1. Last updated
Mar 31, 2023
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/1291/pdf/PSA%2bAPI%2bRequests.pdf "Export page as a PDF")
## HTTP Methods
- POST
- Create an entity or any non-CRUD action
- GET
- Return entity or list of entities
- PUT
- Replace all fields on an entity with supplied fields
- PATCH
- Update specific fields on an entity
- DELETE
- Remove entity
## HTTP Response Statuses
- 201 Created
- The response will be the record that was created as well as the path to it.
- 204 NoContent
- Will be returned by successful delete requests
- 400 Bad Request
- The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications
- 401 Unauthorized
- The supplied authentication is incorrect
- 403 Forbidden
- Improper security role settings for the supplied authentication
- 404 Not Found
- Resource URL not found, this could mean the record was deleted or moved
- 405 Method Not Allowed
- This will occur if you try to use an HTTP method that is not supported by the URL specified
- 409 Conflict
- Record possibly in use or another conflict with the record
- 415 Unsupported Media Type
- This can occur when using the documents API if you are sending the file as a JSON object
- 429 Too Many Requests
- This will occur if request volume exceeds capacity limits and will include a Retry-After header with a numeric value that indicates the number of seconds to wait before retrying (see Retry Policy below) 
- 500 Server Error
- Server errors can occur oftentimes due to a network fault or other non-application-related error, however, In some cases, a 500 error may occur when another error message has not been defined for an error
- These errors should follow the retry logic we recommend and if it appears to be an undefined error, should be reported
> PSA Specific - Are you getting a 404 error or "Could not get any response" on cloud?  You may have incorrect authentication instead of the resource not being found.  To confirm, change your base URL from v4\_6\_release to your ConnectWise PSA version /201x\_x/. 
## Formatting Each Method
### Get
Get requests are used for finding both individual records or a listing of records.  In order to grab an individual entity you must specify an id within the request URL.
```
https://api-na.myconnectwise.net/v4_6_release/apis/3.0/service/tickets/5000
```
When requesting a grouping of records, do not include an id record.  Optionally include parameters at the end of the URL to instead grab a specific set of records.
```
https://api-na.myconnectwise.net/v4_6_release/apis/3.0/service/tickets?conditions=board/name="Integration"%20and%20status/name="new"&page=1&pageSize=10
```
When looking at an endpoint, the parameters section will tell you which fields can be used to filter the request.  In addition to the below parameters, you can use the Fields and Columns to manipulate the response dataset.
#### URL Max Length
The HTTP standards document does not impost a max URL length.  However various browsers, as well as applications, have different limits in place.  In addition search engines favor URLs that are around 2000 characters.  As a URL grows in length it no longer remains user-friendly and becomes unusable or readable by users.  This causes issues with troubleshooting and leads to the overall confusion.  We recommend keeping the URL length of each request to a maximum of 2000 characters.  This will ensure there are no compatibility issues with various servers and configurations.  Please keep in mind that the max length of a domain name can reach 255 characters.  **With that in mind, the safe max length of a URL would be around 1745 characters.**  
This can vary based on environment configurations.  There will often be times when the URL could be significantly longer.  There may also be one of the server configurations based on premise that restricts the URL to a lower length.  However, it is recommended to keep the URL length to a max of 2000 characters including the domain name.
#### Query String Parameters
|**Parameter**|**Description**|**Example**|
|---|---|---|
|[conditions](https://developer.connectwise.com/Best_Practices/PSA_API_Requests?mt-learningpath=manage#Conditions "Manage API Requests")|Search results based on the fields returned in a GET|board/name="Integration"
summary="xyz"
board/id in (3, 2, 4)
lastUpdated > \[2016-08-20T18:04:26Z\]
Only fields returned in a GET request can be used|\=, !=, <, <=, >, >=, contains, like, in, not|
|childConditions|Allows searching arrays on endpoints that list childConditions under parameters|/company/contacts?childconditions=communicationItems/value like "[john@Outlook.com](mailto:%john@Outlook.com% "mailto:%john@Outlook.com%")" AND communicationItems/communicationType="Email"|\=, !=, <, <=, >, >=, contains, like, not|
|[customFieldConditions](https://developer.connectwise.com/Products/ConnectWise_PSA/Developer_Guide#Custom_Fields "Developer Guide")|Allows searching custom fields when customFieldConditions is listed in the parameters|/company/contacts?customFieldConditions=caption="TomNumber" AND value !=null|\=, !=, <, <=, >, >=, contains, like, not|
|orderBy|Choose which field to sort the results by|contact/name asc|asc or desc|
|[fields](https://developer.connectwise.com/Products/ConnectWise_PSA/Developer_Guide#Partial_Responses "Developer Guide")|Limits which information is returned in the response|company/companies?fields=id,name,status/id|Not available on the reporting endpoints|
|[columns](https://developer.connectwise.com/Products/ConnectWise_PSA/Developer_Guide#Partial_Responses "Developer Guide")|Limits which information is returned in the response|system/reports/service?columns=id,summary,name|Only used for the Reporting Endpoints|
|[page](https://developer.connectwise.com/Products/ConnectWise_PSA/Developer_Guide#Pagination "Developer Guide")|Used in pagination to cycle through results|
|[pageSize](https://developer.connectwise.com/Products/ConnectWise_PSA/Developer_Guide#Pagination "Developer Guide")|Number of results returned per page (Defaults to 25)|Max Size = 1,000\*|
|\*Max page size was increased to 1,000 in 2016.2.|
#### Conditions
|Strings|Must be surrounded by quotes|Summary = "This is my string" (Accepts \*'s for Wild Cards)|
|---|---|---|
|Integers|No formatting required|Board/Id = 123|
|Boolean|No formatting is required but must be True or False|ClosedFlag = True|
|Datetimes|Must be surrounded by square brackets|LastUpdated = \[2016-08-20T18:04:26Z\]|
|Operators|<, <=, =, !=, >, >=, contains, like, in, not|Summary Not Contains "Low Priority"|
|Logic Operators|Supported operators include:
- AND
- OR|board/name="integration" and summary="xyz"
board/name="integration" or board/name="professional services"|
|Reference\*|Must have a / followed by the field under the reference you would like to use|manufacturer/name|
#### Using the /Search endpoints
If your request URL is going to be over 10,000 characters long you can instead use the /Search path for certain endpoints.  This allows you to enter the conditions in the body of the request.
```
POST /v4_6_release/apis/3.0/service/tickets/search HTTP/1.1
Host: YOURCONNECTWISESITE
Authorization: Basic AUTHKEY=
Content-Type: application/json
Body:
{
"conditions": "summary like 'test'"
}
```
> Successful get requests will return a 200 status response and a content body of the record(s).
### Patch
Patch requests enable the ability to update individual fields on an entity.  When formatting the request you can specify multiple fields to be updated.  When working with a patch, the entire object is part of an array and must be surrounded by square brackets as per the example below.  If you do not have the square brackets, you will run into errors.  In addition, when you are updating a reference, you can only update it through the use of unique values.  Many times Name is not considered unique.
```
https://api-na.myconnectwise.net/v4_6_release/apis/3.0/service/tickets/5000
```
```
[
{
"op": "string",
"path": "string",
"value": "string"
}
]
```
|op|The update operation used in the request|add|
|---|---|---|
|path|Pathway for the updated field (Case Sensitive)|summary
company|
|value|The new value if doing a replace
Refer to [escaping characters](https://developer.connectwise.com/Products/ConnectWise_PSA/Developer_Guide#Escaping_Characters "Developer Guide")
When working with custom fields, you must pass the entire array of custom fields.|String: "Here is my Summary"
Object: { "identifier": "connectwise" }|
|`01`|`[`|
|---|---|
|`02`|`{`|
|---|---|
|`03`|`"op"``:` `"replace"``,`|
|---|---|
|`04`|`"path"``:` `"summary"``,`|
|---|---|
|`05`|`"value"``:` `"New Summary"`|
|---|---|
|`06`|`},`|
|---|---|
|`07`|`{`|
|---|---|
|`08`|`"op"``:` `"replace"``,`|
|---|---|
|`09`|`"path"``:` `"company"``,`|
|---|---|
|`10`|`"value"``: {`|
|---|---|
|`11`|`"identifier"``:` `"New Company"`|
|---|---|
|`12`|`}`|
|---|---|
|`13`|`},`|
|---|---|
|`14`|`{`|
|---|---|
|`15`|`"op"``:` `"replace"``,`|
|---|---|
|`16`|`"path"``:` `"customFields"``,`|
|---|---|
|`17`|`"value"``: [`|
|---|---|
|`18`|`{`|
|---|---|
|`19`|`"id"``: 5,`|
|---|---|
|`20`|`"caption"``:` `"CloudPlus"``,`|
|---|---|
|`21`|`"type"``:` `"Checkbox"``,`|
|---|---|
|`22`|`"entryMethod"``:` `"EntryField"``,`|
|---|---|
|`23`|`"numberOfDecimals"``: 0,`|
|---|---|
|`24`|`"value"``:` `false`|
|---|---|
|`25`|`},`|
|---|---|
|`26`|`{`|
|---|---|
|`27`|`"id"``: 28,`|
|---|---|
|`28`|`"caption"``:` `"test"``,`|
|---|---|
|`29`|`"type"``:` `"Text"``,`|
|---|---|
|`30`|`"entryMethod"``:` `"List"``,`|
|---|---|
|`31`|`"numberOfDecimals"``: 0,`|
|---|---|
|`32`|`"value"``:` `"test"`|
|---|---|
|`33`|`}`|
|---|---|
|`34`|`]`|
|---|---|
|`35`|`}`|
|---|---|
|`36`|`]`|
|---|---|
> When updating an Object such as Company, you cannot specify a location inside of the object.  You have to replace the whole object like the example above. (Do not use "path":"company/identifier")  If you try to update the Object incorrectly, you may receive a false 200 message.
>
> Successful patch requests will return a 200 status response for success
### Delete
Delete requests are used for removing records from the ConnectWise PSA system.  Please take care when removing items as they cannot be recovered.  The id for the record to be deleted needs to be included in the request URL.
```
https://api-na.myconnectwise.net/v4_6_release/apis/3.0/service/tickets/5000
```
> Successful delete requests will return a 204 status response for No Content.  204 is a success response.
### Post
Post is used when creating new records.  The body of the request must be sent in JSON format.  When sending a Post the response body will include the newly created record id as well as a Get request for the record.  If you pass a post request without filling out every possible value, the system will attempt to default all required information, and anything that does not require a value will be set to Null.
### Put
Put requests are designed to completely replace an entity.  They work the in the same manner as a Post request with the exception that you must specify an already created entity in your URL.  When you use Put to update a record, any field that has not been specified will be overridden with the system defaults or set to Null.
### Escaping Characters
When working with JSON, you may find that you need to use characters that require escaping.  This comes into play primarily when quotes are involved as we do not support many of the characters that require escaping.  Refer to the chart below:
|Character|Escaped|Displayed in the UI|
|---|---|---|
|Double Quotes|\\"|"|
|Backslash|\\\\|\\|
|Tab|\\t|Tabbed space equal to four spaces|
|Backspace (Not Supported)|\\b|An invisible character that doesn't take up any space|
|Carriage Return (Not Supported)|\\r|Single space which is returned as a space in the API instead of an escaped character|
|Newline (Not Supported)|\\n|Single space which is returned as a space in the API instead of an escaped character|
|Form Feed (Not Supported)|\\f|An invisible character that doesn't take up any space|
|**Applies to JSON Bodies Only:** Please note that Single Quotes ', do not require escaping as they should not be used as a container for your strings.  If you are using single contains around string values, you must switch to doubles.|
 URL Encoding is required when using conditions and other URL parameters that have symbols that would denote new query parameters or strings.
|Character|Formatting|
|---|---|
|&|%26|
|"|%22|
|'|%27|
|\*|%2A|
|%|%25|
|+|%2B|
|\[string\]|\[\[\]string\]|
|**This applies to URL Parameters Only**|
### Partial Responses
The API allows you to specify which information you want to be returned.  You do this by listing the request fields or columns on your endpoint URL.  Partial responses work for both GET and POST requests.
|Fields|company/companies?fields=company/id,company/name,phoneNumber|
|---|---|
|Columns (Reporting API only)|system/reports/service?columns=id,summary,company/name|
### Custom Fields
Screens that have custom fields in the ConnectWise PSA UI will have an array of fields on the respective endpoint.  This array can be both queried and updated via the API.  When updating custom fields, you must pass in the entire array object, which means that you cannot patch a single custom field record.  Wondering if an endpoint supports custom fields?  Supported endpoints will have customFields(CustomFieldValue\[\]) listed at the end of the documentation.
There are two methods of adding new custom fields to the ConnectWise PSA environment.  The first method will require access to the ConnectWise PSA thick client and the second method is available in the system/userDefinedFields endpoint within the REST API.
> Refer to the [GET](https://developer.connectwise.com/Products/ConnectWise_PSA/Developer_Guide#Get "Developer Guide") section on how to search custom fields.  Searching custom fields uses customFieldConditions instead of conditions.
In order to access the custom fields via the thick client, navigate to System > Setup Tables > Custom fields,
#### Field Options and Parameters
![Custom Fields.png](https://developer.connectwise.com/@api/deki/files/379/Custom_Fields.png?revision=1&size=bestfit&width=844&height=269)
|**Field**|**Description**|
|---|---|
|Field Caption|Enter a custom field caption for this required field; you are limited to 12 characters.|
|Help Text|The text entered in this field will be displayed when you hover over the help button next to the custom field on the screen.  
**Note:** This has a limit of 1000 characters, however only 512 characters will display on the opportunity screen.|
|Field Type|Select one of the following options:
- **Button** This field allows you to add a hyperlink in the **Button URL** field.  There is a character limit of 1000.
- **Checkbox** This field is used for simple "Yes/No" or "On/Off" answers.  This will display as a checkbox. 
- **Date** This field displays a standard date picker field.
- **Hyperlink** This field displays a text entry field to enter a URL and will be accompanied by a button to visit the hyperlink.
- **Number** This field displays numerical values only.  You will be prompted with an error message that displays 'Option value must be a number' if the text is entered.  
- **Percent** This field displays numerical values only. You will be prompted with an error message that displays 'Option value must be a number' if the text is entered.
- **Text** This field displays alpha-numeric values. There is a character limit of 100.
- **Text** **Area** This field displays alpha-numeric values. There is a character limit of 1000. The **List** and **Dropdown** method of entry is unavailable for this option.
**Note:** If you change a **Date** field to a **Text** or **Text Area** field, the date entered will save as plain text.
If you change a **Text** or **Text Area** field to a **Date** field, the system may not automatically save the date. If this occurs, and it is a required field, the page-level validation will alert the user to enter a date in the field.|
|Number of Decimals|This required field is only available for the Number and Percent Field Type.  You can add up to 5 decimal places.|
|Method of Entry|Select one of the following options:
- **Entry Field** This field is available for all field types.
- **List (Drop-down)** This field is available for the following field types: Number, Percent, and Text.  You are able to enter option values once this entry is selected. 
- **Option (Radio)** This field is available for the following field types: Number, Percent, and Text.  You are able to enter option values once this entry is selected.|
|Sequence #|Enter a sequence number.  This value must be between 1 and 50.|
|Required Field?|Select this checkbox if you would like to make this a required custom field.  This will display as a blue asterisk.|
|Display on Screen?|Select this checkbox if you would like this to display on the Opportunities screen.  This is selected by default.|
|Read Only?|This field is used for the API.  The **Required Field** checkbox cannot be marked for this field.|
|Include on List View?|Select this checkbox if you would like to include this custom field in the My Opportunities list view screen.
**Note:** There is a limit of 10 fields that can be displayed on the list view screen. You can have an unlimited number of custom fields, but the check box will not display if there are already 10 list items. You can unselect this check box at any time.|
|Button URL|Enter the URL if you are using the Field Type Button. The field only appends URLs, no other form of entry will generate content.|
|Select the Locations that will use this Custom Field|This is a required field.  Opportunities that have the specified location(s) will display this custom field.  Select at least one location where the custom field will be used.|
|Select the Departments that will use this Custom Field|This is not a required field. Opportunities that have the specified department(s) will display this custom field.|

View File

@@ -0,0 +1,231 @@
1. Last updated
Feb 4, 2026
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/859/pdf/PSA%2bCallbacks.pdf "Export page as a PDF")
ConnectWise PSA callbacks are payloads of information that are similar to webhooks.  When a record is saved within PSA, a summarized payload is sent to a specified location. 
### Levels and Types
The REST APIs allow a more granular approach to callbacks.  Levels and types open the ability to report on specific boards or tickets without getting unnecessary results.
|**Type**|**Level**|**Description**|
|---|---|---|
|Activities \-|
|Activity|Owner|When set to owner, all ConnectWise PSA activities are returned.|
|Activity|Status|Receive callbacks for activities in the specified status.|
|Activity|Type|Receive callbacks for activities in the specified type.|
|Activity|Company|Receive callbacks for activities under a specific company.|
|Activity|Activity|Receive callbacks for specific activity.|
|Agreements \-|
|Agreement|Owner|When set to owner, all ConnectWise PSA agreements are returned.|
|Agreement|Type|Receive callbacks for agreements in the specified type.|
|Agreement|Company|Receive callbacks for agreements under a specific company.|
|Agreement|Agreement|Recieve callbacks for specific agreement.|
|Companies \-|
|Company|Owner|When set to owner, all ConnectWise PSA companies are returned.|
|Company|Status|Receive callbacks for companies in the specified status.|
|Company|Type|Receive callbacks for companies in the specified type.|
|Company|Territory|Receive callbacks for companies in the specified territory.|
|Company|Company|Receive callbacks for specific company regardless of territory.|
|Company|IntegratorTag|Tag companies to only get updates for that company with one callback record|
|Contacts \-|
|Contact|Owner|When set to owner, all ConnectWise PSA contacts are returned.|
|Contact|Type|Receive callbacks for contacts in the specified type.|
|Contact|Territory|Receive callbacks for contacts in the specified territory.|
|Contact|Company|Receive callbacks for contacts under a specific company.|
|Contact|Contact|Receive callbacks for specific contact.|
|Configurations \-|
|Configuration|Owner|When set to owner, all ConnectWise PSA configurations are returned.|
|Configuration|Type|Receive callbacks for configurations in the specified type.|
|Configuration|Status|Receive callbacks for contacts in the specified status.|
|Configuration|Configuration|Receive callbacks for a specific configuration.|
|Invoice \-|
|Invoice|Owner|When set to owner, all ConnectWise PSA invoices are returned.|
|Invoice|Status|Receive callbacks for invoices in the specified status.|
|Invoice|Company|Receive callbacks for invoices under a specific company.|
|Invoice|Invoice|Receive callbacks for specific invoice.|
|Expense \-|
|Expense|Owner|When set to owner, all ConnectWise PSA expenses are returned.|
|Expense|ChargeToType|Receive  callbacks for expenses under the specified ChargeToType.|
|Expense|Type|Receive callbacks for expenses under the specified Type.|
|Expense|Class|Receive callbacks for expenses under the specified Class.|
|Expense|Company|Receive callbacks for expenses under the specified Company.|
|Expense|Expense|Receive calbacks for specific expense.|
|Member \-|
|Member|Owner|When set to owner, changes to all ConnectWise PSA member records are returned.|
|Member|Location|Receive callbacks for member records associated with the specified Location.|
|Member|SecurityRole|Receive callbacks for member records associated with the specified Security Role.|
|Member|Member|Receive callbacks for changes made to a specified member record.|
|Opportunities \-|
|Opportunity|Owner|When set to owner, all ConnectWise PSA opportunities are returned.|
|Opportunity|StatusId|Receive callbacks for opportunities in the specified status.|
|Opportunity|Company|Receive callbacks for opportunities under a specific company.|
|Opportunity|Opportunity|Receive callbacks for specific opportunity.|
|Product Catalog \-|
|ProductCatalog|Owner|When set to owner, all ConnectWise PSA product catalog items are returned.|
|Projects \-|
|Project|Owner|When set to owner, all ConnectWise PSA projects are returned.|
|Project|Status|Receive callbacks for projects in the specified status.|
|Project|Board|Receive callbacks for projects on the specified board.|
|Project|Project|Receive callbacks for specific project.|
|Purchase Orders \-|
|PurchaseOrder|Owner|When set to owner, all ConnectWise PSA purchase orders are returned.|
|PurchaseOrder|Status|Receive callbacks for purchase orders in the specified status.|
|PurchaseOrder|Vendor|Receive callbacks for purchase orders under the specified Vendor.|
|PurchaseOrder|Company|Receive callbacks for purchase orders under a specific company.|
|PurchaseOrder|PurchaseOrder|Receive callbacks for specific purchase orders.|
|Schedule Entries \-|
|Schedule|Owner|When set to owner, all ConnectWise PSA schedule entries are returned.|
|Schedule|Status|Receive callbacks for schedule entries under a specific status.|
|Schedule|Type|Receive callbacks for schedule entries under a specific type.|
|Schedule|Location|Receive callbacks for schedule entries under a specific location.|
|Schedule|Member|Receive callbacks for schedule entries under a specific member.|
|Schedule|Schedule|Receive callbacks for specific schedule entries.|
|Sites \-|
|Site|Owner|When set to owner, all ConnectWise PSA sites are returned.|
|Site|Territory|Receive callbacks for sites in specified territory.|
|Site|Company|Receive callbacks for sites associated with a specific company.|
|Site|Site|Receive callbacks for specific site.|
|Tickets \-|
|Ticket|Owner|When set to owner, all ConnectWise PSA tickets are returned.|
|Ticket|Board|Receive callbacks for tickets on the specified board.|
|Ticket|Project|Receive callbacks for tickets attached to a specific project.|
|Ticket|Phase|Receive callbacks for tickets attached to a specific project phase.|
|Ticket|Status|Receive callbacks for tickets in the specified status.|
|Ticket|Ticket|Receive callbacks for specific tickets.|
|Ticket|IntegratorTag|Tag tickets to only get updates for that ticket with one callback record|
|Time Entries \-|
|Time|Owner|When set to owner, all ConnectWise PSA time entries are returned.|
|Time|Company|Receive callbacks for tickets for the specified company.|
|Time|Time|Receive callbacks for specific time entries.|
### Configuring the Callbacks
More information can be found within the REST API documentation [Here](https://developer.connectwise.com/Products/ConnectWise_PSA/REST#/CallbackEntries "https://developer.connectwise.com/Products/Manage/REST#/CallbackEntries") with the endpoint system/callbacks.
|**Fields**|**Description**|
|---|---|
|Id|The database record id of the callback; is automatically assigned.|
|Description|This is used to label the callback's usage.|
|URL|This is the URL PSA will send the POST payload to. 
**Note**: PSA appends the record id, and action is taken, to the specified callback URL.
For example, a callback on ticket 7601, turns the callback URL into: [https://mycallbacksite.com/e355a80bc2c107e32](https://mycallbacksite.com/e355a80bc2c107e32 "https://mycallbacksite.com/e355a80bc2c107e32")_?7601&action=updated_
Typically, this is not an issue. However, when it is necessary to add custom parameters to your callback URL, this can cause a problem. For this reason, it is recommended that partners append _&recordId=_ to the end of their callback URL, like: [https://mycallbacksite.com/e355a80bc2c107e32](https://mycallbacksite.com/e355a80bc2c107e32 "https://mycallbacksite.com/e355a80bc2c107e32")**_&recordId=_**. This will ensure that the record id does not interfere with any custom parameters.
Example:
Desired callback URL: [https://api.mycallbackendpoint.com?param1=5&param2=6](https://nam02.safelinks.protection.outlook.com/?url=https%3A%2F%2Fapi.mycallbackendpoint.com%2F%3Fparam1%3D5%26param2%3D6&data=05%7C01%7CRBodford%40connectwise.com%7C3deeae87cf1245c0a98008db0301ab7e%7Cf2ddb62f83354cc99886175b834e4bf3%7C0%7C0%7C638107077950837811%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=6dopNZzGSRoM5HMUn%2BzhlV0UV3DeZizIxiwMajv5eXE%3D&reserved=0 "Original URL: https://api.mycallbackendpoint.com/?param1=5&param2=6. Click or tap if you trust this link.")
Currently, a callback on ticket number 2475 turns the callback URL into:
[https://api.mycallbackendpoint.com?param1=5&param2=62475&action=updated](https://nam02.safelinks.protection.outlook.com/?url=https%3A%2F%2Fapi.mycallbackendpoint.com%2F%3Fparam1%3D5%26param2%3D62475%26action%3Dupdated&data=05%7C01%7CRBodford%40connectwise.com%7C3deeae87cf1245c0a98008db0301ab7e%7Cf2ddb62f83354cc99886175b834e4bf3%7C0%7C0%7C638107077950837811%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=2BxWDta4VObPj81GAHDOWTxyrtYmj3AxTo8MYp3mxv4%3D&reserved=0 "Original URL: https://api.mycallbackendpoint.com/?param1=5&param2=62475&action=updated. Click or tap if you trust this link.")
Notice the value of param2 is now changed from 6 to 62475 because the ticket number was appended.
This results in the following URL:
[https://api.mycallbackendpoint.com?param1=5&param2=6&recordId=2475&action=updated](https://nam02.safelinks.protection.outlook.com/?url=https%3A%2F%2Fapi.mycallbackendpoint.com%2F%3Fparam1%3D5%26param2%3D6%26recordId%3D2475%26action%3Dupdated&data=05%7C01%7CRBodford%40connectwise.com%7C3deeae87cf1245c0a98008db0301ab7e%7Cf2ddb62f83354cc99886175b834e4bf3%7C0%7C0%7C638107077950994009%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C3000%7C%7C%7C&sdata=GwH9Y77%2Be5wgOxXwy%2FD1HXx4XRII%2FgE8eFCu7pyLafw%3D&reserved=0 "Original URL: https://api.mycallbackendpoint.com/?param1=5&param2=6&recordId=2475&action=updated. Click or tap if you trust this link.")|
|ObjectId|The ObjectId should be the Id of whatever record you are subscribing to. This should be set to 1 when using a level of Owner.|
|Type|This is the specific type of record such as Company, Ticket, Contact, etc... See the associated table for all values.|
|Level|The level is used to determine how granular the callback subscription will be.  See the associated table for all values.|
|MemberId|This is a read-only value that shows who initially created the Callback.|
|InactiveFlag|Used to determine if the callback is active and sending requests.|
|\_Info|This section has additional metadata about the record and is included on all API requests as read-only data.|
|`01`|`POST /v4_6_release/apis/3.0/system/callbacks`|
|---|---|
|`02`|`{`|
|---|---|
|`03`|`"id"``: 0,`|
|---|---|
|`04`|`"description"``:` `"maxLength = 100"``,`|
|---|---|
|`05`|`"url"``:` `"Sample string"``,`|
|---|---|
|`06`|`"objectId"``: 0,`|
|---|---|
|`07`|`"type"``:` `"Sample string"``,`|
|---|---|
|`08`|`"level"``:` `"Sample string"``,`|
|---|---|
|`09`|`"memberId"``: 0,`|
|---|---|
|`10`|`"inactiveFlag"``:` `"false"``,`|
|---|---|
|`11`|`"_info "``: {` `"lastUpdated"``:` `""``,` `"updatedBy"``:` `""` `}`|
|---|---|
|`12`|`}`|
|---|---|
### Testing Callbacks
#### Webhook.Site
When testing the ConnectWise PSA callbacks there are a number of useful browser-based tools.  One of note is [https://webhook.site](https://webhook.site/ "https://webhook.site").
Simply use the New button to create a URL and then the Copy URL button to save the URL generated to your clipboard and add this as your ConnectWise PSA callback URL.
```
https://webhook.site/xxxxxxx-xxxx-xxxx-af13-855ab85aebae
```
### When Callbacks Fail to POST to Target Host
When a callback receives an error when attempting to POST the callback payload to the host specified in the callback URL, ConnectWise PSA will retry the POST for any 404, 409, 419, or 429 error responses. ConnectWise PSA retries twice, once two seconds after the initial POST attempt and again four seconds after the first retry attempt. Requests will timeout after five seconds.
The system counts how many consecutive days the callback fails, and after three consecutive days of failed attempts, ConnectWise PSA will disable the callback
Any 2xx response is considered successful.
For partners with an on-premise instance of ConnectWise PSA, when troubleshooting why your callback POSTs are receiving error response from the remote host, logs for the service are found on the ConnectWise PSA frontend server at C:\\Program Files\\ConnectWise\\ApiCallbackService\\logs\\server.log
To enable verbose logging, change the minlevel value to minlevel=”Info” in the  C:\\Program Files\\ConnectWise\\ApiCallbackService\\ApiCallbackService.exe.nlog
### Verifying the Callback Source
Callbacks contain a key\_url in the metadata section that can be used to verify the source of the callback.  The key\_url returns the signing key which then can be used in conjunction with the below code sample and the x-content-signature.
|`1`|`using` `(var sha =` `new` `SHA256Managed())`|
|---|---|
|`2`|`{`|
|---|---|
|`3`|`var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(sharedSecretKey));`|
|---|---|
|`4`|`using` `(var hmac =` `new` `HMACSHA256(hash))`|
|---|---|
|`5`|`{`|
|---|---|
|`6`|`return` `Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(jsonPayload)));`|
|---|---|
|`7`|`}`|
|---|---|
|`8`|`}`|
|---|---|
### Callback Changelog 
|**Supported Version**|**Changes**|
|---|---|
|ConnectWise PSA 2020.2|Added callbacks for Configurations|
|ConnectWise PSA 2016.6|Added callbacks for Invoice
Added callbacks for Projects
Added callbacks for Activities|
|ConnectWise PSA 2016.5|Company Callbacks: Added Status and Type levels
Contact Callbacks: Added Type level
Ticket Callbacks: Added levels for tracking Project Tickets|
|ConnectWise PSA 2016.4|Added callbacks for Opportunities|
|ConnectWise PSA 2016.3|Added callbacks for Companies and Contacts|
|ConnectWise PSA 2015.6|Added callbacks for Tickets|

View File

@@ -0,0 +1,51 @@
1. Last updated
Apr 8, 2025
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/959/pdf/PSA%2bCloud%2bURL%2bFormatting.pdf "Export page as a PDF")
When making calls to the ConnectWise PSA API, we have a URL that will give you the exact codebase to target in place of v4\_6\_release.  This is useful to see if someone is on a cloud environment programmatically.  Additionally, the request can be routed directly to the correct PSA version for the partner without it going through another source.
## Calling Company Info
```
"https://" + ConnectWiseSite + "/login/companyinfo/" + LoginCompanyId
https://na.myconnectwise.net/login/companyinfo/connectwise
```
#### Response
```
{
"CompanyName":"ConnectWise",
"Codebase":"v2017_3/",
"VersionCode":"v2017.3",
"VersionNumber":"v4.6.38842",
"CompanyID":"CW",
"IsCloud":"True" *Added in 2016.5
}
```
## API Request URL Format
```
"https://" + ConnectWiseSite + "/" + codebase + "apis/3.0/company/companies"
https://api-my.myconnectwise.net/v2017_3/apis/3.0/company/companies
```
## Cloud vs Premise
A cloud environment will return a codebase with the PSA version.  On-Premise does not use URL redirection and will return v4\_6\_release/.  If your returned codebase contains anything other than v4\_6\_release/, you will need to ensure your request is prefixed by API-
## Cloud URLs
These are the most commonly used URLs for the cloud.
```
https://na.myconnectwise.net
https://eu.myconnectwise.net
https://au.myconnectwise.net
https://aus.myconnectwise.net
https://za.myconnectwise.net
https://staging.connectwisedev.com
```

View File

@@ -0,0 +1,40 @@
1. Last updated
Mar 31, 2023
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/1259/pdf/PSA%2bCompany%2bSynchronization.pdf "Export page as a PDF")
## Overview
The single most resource-intensive part of a typical integration is company synchronization.  Many times integrations will cycle through company records that are irrelevant in terms of the integration itself.  This could be cycling through prospects, former clients, or even duplicate records.
## What are Companies?
Companies are the primary key for many data sets within the ConnectWise PSA system.  ConnectWise PSA is multi-tenant in that each company record inside of it is a representation of a client that they support or want to support.  What this means is that if you wanted to create a service ticket, you have to specify what company it would belong to.  You cannot have a service ticket without it being associated with a company.  Similarly, you cannot have an agreement, opportunity, invoice, configuration, or any major record type without specifying what company it belongs to.
### Business Case
There are many benefits to ensuring that your integration has an optimization company synchronization flow.  These range from a faster integration to a much easier-to-utilize integration as you won't be displaying irrelevant information.
- Reduce the number of API Calls made
- Speed up the process of mapping to PSA Companies
- Add value to your integration that will impress partners
## Implementation
First you need to identify what the is the record in your system that would be tied to a company in ConnectWise PSA.  Once you know what record you are going to sync across you need to start with making a method for the partner to correspond the records together.  There are a number of ways that integrators have done this in the past.  Some of these are more advanced than others and in order to become a certified integrator, you cannot use any methods that require manual entry of data.  It must be transmitted by the API.  There are a number of recommended approaches that should be taken for each integration.
- Add a field to your equivalent screen to select the ConnectWise PSA company
- Add a new screen that is a mass association tool that lists each of your records with a field to set the ConnectWise PSA company
- Add an option to import ConnectWise PSA companies
- Add an option to export companies to ConnectWise PSA
After you know what areas you are going to add the synchronization to, you have to work on filtering the data you are looking for to be usable by your integration.  This is where many people make a mistake and they don't filter the data they are looking for and instead end up with many records that are unnecessary or irrelevant for the integration.  Many times this means that when someone goes to select a company to map, integrators may load all 50,000 companies in ConnectWise PSA to pick from, whereas the partner only has 1,500 that are actually active clients.  The rest of the results could be prospects, old clients, and duplicates.
To begin filtering records, you need to know how a company is categorized within ConnectWise PSA.  There are two fields in the UI that control this, which, are the Status and Type fields.  Each company can only have one status but many types.  The status is used primarily as an indicator of it being Active, Inactive, or even in Credit Hold.  The type is used to determine the specific type of customer, client, or prospect as well as many other options.
![clipboard_e42993a313a2fa1e7dca47f1ef7068924.png](https://developer.connectwise.com/@api/deki/files/472/clipboard_e42993a313a2fa1e7dca47f1ef7068924.png?revision=1)
_Fig.1 - The company status and type fields are found on the company finance screen within ConnectWise PSA._
> Statuses and Types are customizable.  You cannot compare them between environments or expect every environment to have the same values or ids.  Each integration should be using API calls to grab a listing of each per environment in order to dynamically filter data.

View File

@@ -0,0 +1,23 @@
1. Last updated
Mar 31, 2023
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/1653/pdf/PSA%2bData%2bProtection.pdf "Export page as a PDF")
## Protecting your Information
The ConnectWise PSA API uses security roles to determine access to environments.  By selecting ALL instead of My or None you give integrators access to every piece of information.  For certain areas, this may make sense.  If you are adding your existing companies to their solution, or if you are sending tickets to them from ConnectWise PSA.  However, in other cases such as an AV Alert or Backup Failure alert, integrations dont need access to tickets they havent created themselves.  Integrations that impact agreements, dont need to see additions that they dont manage.  By providing access to ALL, integrations may be reading data that you wouldnt expect, or updating information that they dont own.  It is always best practice to select MY access for all security roles unless it makes sense to enable ALL access.  Integrations should justify requests for ALL access.  There is never a situation in which integration needs “admin” access.  This is often the default request if they dont know the exact roles required.  [API Logging](https://docs.connectwise.com/ConnectWise_Documentation/090/040/010/040/040?psa=1 "https://docs.connectwise.com/ConnectWise_Documentation/090/040/010/040/040?psa=1") will tell you if you restrict integrations too far.
> Want to learn more about [ConnectWise PSA Security Roles](https://docs.connectwise.com/ConnectWise_Documentation/090/025#Security_Role_Levels_Setting "https://docs.connectwise.com/ConnectWise_Documentation/090/025#Security_Role_Levels_Setting")?
## How is data stored?
ConnectWise does not store any information passed via the API outside of respective partner environments.  We do not have a central API Service, data is sent directly to the front ends of each environment.  The data from each partner is separate from every other partner, just because you can access one partner, does not mean you can access another.  This means that if any integration has access to the APIs, they can only see data relating to the exact partner that gave them access to the APIs.  This also means if you are an integrator, you must be given individual API Keys for every environment you wish to access.  Similarly, no other vendors can see anything relating to your data if a partner hasnt specifically given them API keys.
![Partner information is isolated to their own database](psa-data-protection/SilosOfData.png)
In this example Vendor A only has access to Partner A's information, because only Partner A has provided them with API Keys.  Partner B on the other hand, is using both Vendor A and Vendor B.  Depending on the security role permissions, this means Vendor B and Vendor A could see information from each other Partner environment A.  If Partner A sets each integration to MY level access, then neither integration could see the other.
## Can another vendor access my information?
All information passed into a partner environment is only accessible if the partner has explicitly also given that other vendor access to that data.  This means that they had to provide API Keys to the vendor and give them a security role other than MY or NONE.

View File

@@ -0,0 +1,70 @@
## Endpoints
ConnectWise PSA supports Markdown formatting for notes such as on Ticket Detailed Descriptions. Here are the supported formatting options. 
|Formatting Type|API Markdown|Outcome in Ticket Notes|
|---|---|---|
|Bold|\*\*This is bold\*\*|**This is bold**|
|Italic|\*This is italic\*|_This is italic_|
|Underlined|\_\_This is underlined\_\_|<u>This is underlined</u>|
|Bold Italic|\*\*\*This is Bold Italic\*\*\*|_**This is Bold Italic**_|
|Bold Underlined|\*\*\_\_This is Bold Underlined\_\_\*\*|**<u>This is Bold Underlined</u>**|
|Underlined Italic|\*\_\_This is Underlined Italic\_\_\*|_<u>This is Underlined Italic</u>_|
|Bold Underlined Italic|\*\*\*\_\_This is Bold Italic Underlined\_\_\*\*\*|_<u><strong>This is Bold Italic Underlined</strong></u>_|
|Unformatting Formatted
text|\\\\\*We have 10 pallets of rock \\n\*We have 5 rocks per pallet|\*We have 10 pallets of rock
\*We have 5 rocks per pallet|
|Ordered Lists|1\. Regular Ordered List 1\\n2. Regular Ordered List 2|1\. Regular Ordered List 1
2\. Regular Ordered List 2|
|Italic Ordered Lists|1\. \*Italics Ordered List 1\*\\n2. \*Italics Ordered List 2\*|1. _Italics Ordered List 1_
2. _Italics Ordered List 2_|
|Bold Ordered Lists|1\. \*\*Bold Ordered List 1\*\*\\n2. \*\*Bold Ordered List 2\*\*|1. **Bold** **Ordered List 1**
2. **Bold** **Ordered List 2**|
|Underlined Ordered Lists|1\. \_\_Underlined Ordered List 1\_\_\\n2. \_\_Underlined Ordered List 2\_\_|1. <u>Ordered List 1</u>
2. <u>Ordered List 2</u>|
|Underlined Link - Ordered List|1\. \_\_\[Ordered List 1\]([www.google.com/](http://www.google.com/))\_\_\\n2. \_\_\[Ordered List 2\]([www.google.com/](http://www.google.com/))\_\_|1. <u><a href="https://www.google.com/" rel="nofollow">Ordered List 1</a></u>
2. <u><a href="https://www.google.com/" rel="nofollow">Ordered List 2</a></u>|
|Italic Link - Ordered List|1\. \*\_\_\[Italic Ordered List 1\]([www.google.com/](http://www.google.com/))\_\_\*\\n2. \*\_\_\[Italic Ordered List 2\]([www.google.com/](http://www.google.com/))\_\_\*|1. _<u><a href="http://www.google.com/" rel="nofollow">Italic Ordered List 1</a></u>_
2. _<u><a href="http://www.google.com/" rel="nofollow">Italic Ordered List 2</a></u>_|
|Bold Link - Ordered List|1\. \*\*\_\_\[Bold Ordered List 1\]([www.google.com/](http://www.google.com/))\_\_\*\*\\n2. \*\*\_\_\[Bold Ordered List 2\]([www.google.com/](http://www.google.com/))\_\_\*\*|1. **<u><a href="http://www.google.com/" rel="nofollow">Bold Ordered List 1</a></u>**
2. **<u><a href="http://www.google.com/" rel="nofollow">Bold Ordered List 2</a></u>**|
|Bold Italic Link - Ordered List|1\. \*\*\*\_\_\[Bold Italic Ordered List 1\]([www.google.com/](http://www.google.com/))\_\_\*\*\*\\n2. \*\*\*\_\_\[Bold Italic Ordered List 2\]([www.google.com/](http://www.google.com/))\_\_\*\*\*|1. **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Ordered List 1</a></u>_**
2. **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Ordered List 2</a></u>_**|
|Bold Underlined Link - Ordered List|1\. \*\*\_\_\[Bold Underlined Ordered List 1\]([www.google.com/](http://www.google.com/))\_\_\*\*\\n2. \*\*\_\_\[Bold Underlined Ordered List 2\]([www.google.com/](http://www.google.com/))\_\_\*\*|1. **<u><a href="http://www.google.com/" rel="nofollow">Bold Underlined Ordered List 1</a></u>**
2. **<u><a href="http://www.google.com/" rel="nofollow">Bold Underlined Ordered List 2</a></u>**|
|Italic Underlined Link - Ordered List|1\. \*\_\_\[Italic Underlined Link Ordered List 1\]([www.google.com/](http://www.google.com/))\_\_\*\\n2. \*\_\_\[Italic Underlined Link Ordered List 2\]([www.google.com/](http://www.google.com/))\_\_\*|1. _<u><a href="http://www.google.com/" rel="nofollow">Italic Underlined Link Ordered List 1</a></u>_
2. _<u><a href="http://www.google.com/" rel="nofollow">Italic Underlined Link Ordered List 2</a></u>_|
|Regular Unordered List|\* Regular Unordered List\\n\* Regular Unordered List|- Regular Unordered List
- Regular Unordered List|
|Bold Unordered List|\* \*\*Bold Unordered List\*\*\\n\* \*\*Bold Unordered List\*\*|- **Bold Unordered List**
- **Bold Unordered List**|
|Italic Unordered List|\* \*Italic Unordered List\*\\n\* \*Italic Unordered List\*|- _Italic Unordered List_
- _Italic Unordered List_|
|Underlined Unordered List|\* \_\_Underlined Unordered List\_\_\\n\* \_\_Underlined Unordered List\_\_|- <u>Underlined Unordered List</u>
- <u>Underlined Unordered List</u>|
|Bold Link - Unordered List|\* \*\*\_\_\[Bold Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\\n\* \*\*\_\_\[Bold Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*|- **<u><a href="http://www.google.com/" rel="nofollow">Bold Link Unordered List</a></u>**
- **<u><a href="http://www.google.com/" rel="nofollow">Bold Link Unordered List</a></u>**|
|Italic Link - Unordered List|\* \*\_\_\[Italic Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\\n\* \*\_\_\[Italic Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*|- _<u><a href="http://www.google.com/" rel="nofollow">Italic Link Unordered List</a></u>_
- _<u><a href="http://www.google.com/" rel="nofollow">Italic Link Unordered List</a></u>_|
|Underlined Link Unordered List|\* \_\_Underlined Unordered List\_\_\\n\* \_\_Underlined Unordered List\_\_|- <u>Underlined Link Unordered List</u>
- <u>Underlined Link Unordered List</u>|
|Bold Italic Link - Unordered List|\* \*\*\*\_\_\[Bold Italic Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\*\\n\* \*\*\*\_\_\[Bold Italic Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\*|- **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Unordered List</a></u>_**
- **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Unordered List</a></u>_**|
|Bold Underlined Link - Unordered List|\* \*\*\_\_\[Bold Underlined Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\\n\* \*\*\_\_\[Bold Underlined Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*|- **<u><a href="http://www.google.com/" rel="nofollow">Bold Underlined Unordered List</a></u>**
- **<u><a href="http://www.google.com/" rel="nofollow">Bold Underlined Unordered List</a></u>**|
|Italic Underlined Link - Unordered List|\* \*\_\_\[Italic Underlined Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\\n\* \*\_\_\[Italic Underlined Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*|- _<u><a href="http://www.google.com/" rel="nofollow">Italic Underlined Unordered List</a></u>_
- _<u><a href="http://www.google.com/" rel="nofollow">Italic Underlined Unordered List</a></u>_|
|Link|\_\_\[link\]([www.google.com](http://www.google.com/))\_\_|<u><a href="http://www.google.com/" rel="nofollow">link</a></u>|
|Bold link|\*\*\_\_\[Bold link\]([www.google.com](http://www.google.com/))\_\_\*\*|**<u><a href="http://www.google.com/" rel="nofollow">Bold link</a></u>**|
|Italics link|\*\_\_\[Italics link\]([www.google.com](http://www.google.com/))\_\_\*|_<u><a href="http://www.google.com/" rel="nofollow">Italics link</a></u>_|
|Underlined link|\_\_\[Underlined link\]([www.google.com](http://www.google.com/))\_\_|<u><a href="http://www.google.com/" rel="nofollow">Underlined link</a></u>|
|Bold Italic link|\*\*\*\_\_\[Bold Italic link\]([www.google.com](http://www.google.com/))\_\_\*\*\*|**_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic link</a></u>_**|
|Bold Underlined link|\*\*\_\_\[Bold Underlined link\]([www.google.com](http://www.google.com/))\_\_\*\*|**<u><a href="http://www.google.com/" rel="nofollow">Bold Underlined link</a></u>**|
|Italics Underlined link|\*\_\_\[Italics Underlined link\]([www.google.com](http://www.google.com/))\_\_\*|_<u><a href="http://www.google.com/" rel="nofollow">Italics Underlined link</a></u>_|
|Bold Italic Underlined Link - Unordered List|\* \*\*\*\_\_\[Bold Italic Underlined Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\*\\n\* \*\*\*\_\_\[Bold Italic Underlined Unordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\*|- **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Underlined Unordered List</a></u>_**
- **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Underlined Unordered List</a></u>_**|
|Bold Italic Underlined Link - Ordered List|1\. \*\*\*\_\_\[Bold Italic Underlined Ordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\*\\n2. \*\*\*\_\_\[Bold Italic Underlined Ordered List\]([www.google.com/](http://www.google.com/))\_\_\*\*\*|1. **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Underlined Ordered List</a></u>_**
2. **_<u><a href="http://www.google.com/" rel="nofollow">Bold Italic Underlined Ordered List</a></u>_**|
|Image|!\[\]([https://media.giphy.com/media/LVrlKiw0VCl5lEMt4f/giphy.gif](https://media.giphy.com/media/EldfH1VJdbrwY/giphy.gif))|

View File

@@ -0,0 +1,122 @@
1. Last updated
Mar 31, 2023
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/1266/pdf/PSA%2bPagination.pdf "Export page as a PDF")
## Overview
When working with ConnectWise PSA instances, you will run into a significant amount of data.  This guide should help you to work with and manipulate that data for your integration.  
## Pagination Defined
The concept of pagination is similar to that of pages within a book or when you are navigating a website or forum and there is a button to go to the next page.  Simply put, when you have a large set of data, pagination breaks it up into different pages for consumption.  The below is a perfect example of the pagination concept that you have probably seen before.
![pagination.png](https://developer.connectwise.com/@api/deki/files/462/pagination.png?revision=1)
Pagination with APIs works a little bit differently but has the same underlying concept. When you try to retrieve a list of objects that is too large, either for the computational costs associated with consumption or any of the plethora of networking concerns (waiting for a few gigs of data from an API call can have unexpected effects on your application), the API will respond with information about how to access the next page of results.  Instead of flipping through pages either physically or with a navigation tree, the API has a series of Link headers that allow you to do it programmatically without human interaction.
Business Case
ConnectWise products store a significant amount of data and often times developers may not understand the impact of their integration on a given environment.  We have set a hard limit of 1,000 records for the protection of the Partner environments.  Many integrations do not need data past this point and if we didn't have a set limit, it allow for the API to potentially lock records or cause overall performance problems.
## Implementation
There are two different methods to Pagination, each has it's own benefit or drawback depending on the type of integration you are creating.
|Type|Pros|Cons|Usage|
|---|---|---|---|
|Navigable|Allows you to create a UI that allows for human interaction in going back and forth between records.|Uses a large number of resources and results are returned slower as you get into the higher page sizes.|This example returns the first 100 results.
service/tickets?pagesize=100&page=1|
|Forward Only|Each call will return results at the same speed as the last.  Data is returned from an indexed location.|There is no way to page backward.  This means you can't design a navigable UI around it.|This example returns the first 100 results after Ticket#40941
service/tickets?pagesize=100&conditions=id>1231|
Each version of paging will return a series of Link headers to help with navigation.  Let's dive into each Pagination Method and how to utilize them.
## Navigable Pagination
The ConnectWise PSA API provides a vast wealth of information for developers to consume.  Most of the time, you will find that there is far more information than is needed for integration.  In order to ensure server availability, the API automatically sets the page size to 25 results.  The maximum page size for any request can be up to 1,000 records if properly specified in the request URL.
|pagesize|The number of results returned by each call.  Defaulted to 25 and has a maximum of 1,000.  These values cannot be changed.|
|---|---|
|page|Starting with page 1, is the number of pages available based on the current pagesize.|
When using paging, the response will include a [Link header](https://tools.ietf.org/html/rfc5988 "https://tools.ietf.org/html/rfc5988") that looks like this:
|`1`|`/v``4``_``6``_release/apis/``3.0``/company/companies?pagesize=``50``&page=``1`|
|---|---|
If you notice, the header contains two separate URLs in this instance.  The first one is "next" and the second one is "last".  These refer to the next set of results and the final set of results.  If we navigate to the next page, we will get some additional URLs in the response Link header.
|`1`|`/v``4``_``6``_release/apis/``3.0``/company/companies?pagesize=``50``&page=``2`|
|---|---|
Now that we have navigated to the second page, we will find two additional URLs.  These are "prev" and "first" and in total we now have four including the original "next" and "last".
|First|This link will display the first page available based on the current page size|
|---|---|
|Prev\*|This link will display the previous page based on the current page position and page size|
|Next\*|This link will display the next available page based on the current page position and page size|
|Last|This link will always display the final page available based on the current page size|
\*We will not return the next or prev links if there isn't a next or prev respectively.
In the above examples, we have four pages of results and depending on the page size, that number can change.  For instance, let's say we switch to the default page size of 25.  Instead of returning four pages, we actually get back 7 pages as there are only 160 results.
The goal of pagination is not to pull every possible result, every single time.  Instead, it is designed to be in conjunction with a UI component to create a set of navigation links.
![clipboard_e29fb7e94d767bfdc2a4db21d834fef74.png](https://developer.connectwise.com/@api/deki/files/280/clipboard_e29fb7e94d767bfdc2a4db21d834fef74.png?revision=1&size=bestfit&width=604&height=67)
> Navigable Pagination closely follows [RFC 5988](https://tools.ietf.org/html/rfc5988 "https://tools.ietf.org/html/rfc5988").
## Forward-Only Pagination
Released in 2018.5
Unlike Navigable Pagination, forward-only requires that you pass in a header to identify the type of pagination you would like to use.  For instance, we now accept a header called pagination-type, and when you set that to forward-only you will get to utilize the new features.  In addition to the new header, there is a new query parameter called pageId.  The pageId is the record in which you would like to begin with for paging.
- If you do not include the new header in your request, it will use the default paging method of navigable. 
- The Page query parameter will be ignored with forward-only paging as all results are technically page 1.
- You cannot use an Order By query parameter with forward-only as it must be ordered by the ID.
- There will always be a link header in the response for the next pageId.
- The pageId query parameter is treated like an additional condition of Id > pageId.
- The following pageId in the header will be the last Id you got in the request.
![clipboard_ebcad30cfa48ce47932d2c0b47108b933.png](https://developer.connectwise.com/@api/deki/files/473/clipboard_ebcad30cfa48ce47932d2c0b47108b933.png?revision=1)
![clipboard_ef5cbb3427dc4eaba39453ea2dbcaf2c3.png](https://developer.connectwise.com/@api/deki/files/474/clipboard_ef5cbb3427dc4eaba39453ea2dbcaf2c3.png?revision=1)
> Forward-Only pagination does not work with the Audit Trail endpoints at this time.
## How to Get All Records?
In most cases, when you experience pagination, you likely want all of the items in the list and arent very happy about the extra work required to page through the results. Here are some best practices to make it a little less painful to get all of your results:
### Avoid it
Although this doesn't sound ideal, conceptually this is how you will be able to get the most performance.  Many APIs allow you to query for items based on specific criteria or only return certain subsets of the data.  The PSA API is no different and for example, allows you to query based on any number of fields including time ranges.  If you know anything about the data you are looking for, you can narrow down the result set from the beginning, in many cases eliminating pagination and giving you a performance boost in comparison to over-fetching and then filtering in your application.
### Pay Attention to the Headers
We only return the paging headers if there is something to the page.  If you don't see the next page, don't try to query for the next page number.  Use the paging headers programmatically to achieve your results and avoid trying to make them on your own.
### While looping
Depending on your programming language of choice, pagination can be a wonderful use case for While.  The basic workflow is that while you are getting a pagination token in your response, keep making subsequent requests.  In pseudo-code that might look like this:
```
Page = GetPageOfItems();
//process the data from the page, or add it to a larger array, etc.
```
```
while( Page->cursor )
Page = GetPageOfItems(Page->cursor);
//process the data again
end
```
As soon as your API stops responding with a pagination cursor, then you can stop looping and continue execution with the complete set of data.  There are a couple of important points to remember with an approach like this:
- Any errors from the API that dont return a cursor might prematurely stop your execution, so always check that you get a good response from the API.
- You probably want to include some checks to make sure that you stop bringing in more information at some upper limit, just in case the API responds with much more information than you expect and keeps paging, and paging, and paging…

View File

@@ -0,0 +1,48 @@
1. Last updated
Mar 31, 2023
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/1258/pdf/PSA%2bService%2bTickets.pdf "Export page as a PDF")
## Overview
Anything that is an actionable item should become a ticket. Today, the natural course of business is to send tasks and get things done via email. If you want to stick with email, you can send the email in to create a service ticket on the service board.
## Service Tickets
|Tickets are crucial to the success of any ConnectWise PSA integration.  There are many different types of tickets and reasons for creating them:
|---|
1. Anything that requires work that you would spend time on
2. Alert
3. Portal request
4. Client question
5. Client request
6. Voicemail
7. Email
8. Internal request
When everything is a ticket, you are creating that accountability loop to ensure that tasks get completed. Keep in mind that tickets are the building blocks for projects, and they can be imported into a project.
> Want to learn more? Watch the <u><a href="https://www.youtube.com/watch?v=5TQU5scQ8eA" rel="external nofollow" target="_blank" title="https://www.youtube.com/watch?v=5TQU5scQ8eA">Everything is a Ticket</a></u> video.|
## Business Case
|When it comes to ConnectWise, all roads lead to Rome. This concept means all things should be in ConnectWise. As an integrator, the pathway to mutual success is creating tickets. Tickets can come from:
|---|
1. Calls
2. Emails
3. Alerts
4. Issues
There are many intricacies with service tickets and the available fields that can be used to categorize, classify and add to the existing workflows of our mutual partners.  From the figures provided, you can see that there are a large number of fields available for creating a tight integration experience.|
## Fields
|When it comes to actually creating and updating tickets there are many fields that can be utilized in order to create the best integration experience for the mutual partner.
|---|
When you are going through the initial integration setup, it is a good idea to map to as many of recommended fields as possible.  A baseline integration is simply going to update a summary line and select a single service board.
A best-in-class integration is going to be able to take all or most of the recommended fields and be able to build upon those in order to ensure that the integration is compatible with each partner's workflow.|**Summary** \- The subject line or description line for the ticket.
**Board** \- The service board determines which team will be working on the service ticket after creation.  This also helps to determine billing options.
**Status** \- Statuses should be changed as the work for the service ticket progresses. Statuses are defined in the Status Tab of the Service Board setup table
**Type** \- The type of service you are performing for this service ticket. Service types are defined in the Types Tab of the Service Board setup table.
**Subtype** \- Subtypes are used to provide further detail to your main service type.
**Item** \- Items are used to provide even further detail to your main service types and subtypes.  Partners can use items to set up automated task additions to tickets as they are created based on service templates.
**Priority** \- The impact and urgency are defined as a singular value to determine the priority of a ticket.  Typically defined by a color.
**Source** \- Where the ticket originated.  This is usually something such as Email Connector, Phone, or from Integration.|

View File

@@ -0,0 +1,50 @@
1. Last updated
Mar 31, 2023
2. [Save as PDF](https://developer.connectwise.com/@api/deki/pages/1118/pdf/PSA%2bVersioning.pdf "Export page as a PDF")
Starting with 2019.1 the API is now tied to the ConnectWise PSA release cycle.  Previously the API was versioned as it's own entity and followed a format such as 3.0.1.  To provide clarity around the API Versioning and to make it easier for consumers, we have replaced 3.0.0 with the specific ConnectWise PSA version that the API model was released on.
If you develope your API against 2019.1 and pass in the version 2019.1, you will continue to get the models for 2019.1 even if we change them in 2019.2.  What this means is that each time we release a breaking change, you have a period of time to switch over to the new version.  We do not release new models every release, however you can still target a version that did not have any changes.  For instance, lets say in 2019.1 we have a specific ticket model called A.  In 2019.4 we update tickets to have model B.  If you pass 2019.1, 2019.2 or 2019.3, you will get model A, which is 2019.1.
By default all requests will receive the latest version of our API endpoint.  As breaking changes are released, each individual API endpoint, may receive an updated version.  In order to prevent your integration from breaking, we encourage you to request a specific version using an Accept header.  For all production level integrations we recommend using this Accept header concept and for all development and testing work, we recommend using the latest version of the API.  We will regularly deprecate old models of the API.
### What is a breaking change?
Breaking changes are defined as any change to the APIs that could result in an error being returned by the SDK.  The SDK is a JSON interpreter and as long as you develop your code the way a modern JSON interpreter would, you shouldn't have any issues.  If you hard code every field, you will have issues that we don't consider breaking.
#### **Examples of breaking changes:**
- Renaming a field (fixing a typo)
- Changing a field's data type (such as from an int to a decimal, or from editable to read only)
- Changing the response type (such as from an object to an array of objects)
- Validation changes (fields now required that previously were not)
#### **NOT a breaking change:**
- Adding new fields
- Adding new endpoints
- Making a read only field editable
- Making a field no longer required if it was previously required
> As we release breaking changes the individual endpoint will be versioned when applicable. The previous version will be immediately be considered deprecated but supported for 12 months.  Please note that some endpoints cannot be versioned.
### Formatting Version Header
We try to make it as simple as possible to format your version header.  We use an accept header that lists our JSON schema as well as the version following.  Accept headers tell the server what type of information you are expecting to get back.  This makes it a perfect fit to add our version to as this tells the server that you want xyz version back.
|`1`|`Accept: application/vnd.connectwise.com+json; version=``2019.1`|
|---|---|
### Per Endpoint
If you are not ready to update to the latest API Models for your entire integration, you can change your accept header to be on an endpoint by endpoint basis.  We recommend that you keep a singular version across your integration, but will support it either way.  Anytime you test your integration and validate it against a version, update your version header even if we are not deprecating the version you are on.  We may do so without warning, or give you very little time to make changes.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,3 +22,4 @@ export { assistantChatApi } from './assistantChat'
export { flowTransferApi } from './flowTransfer'
export { kbAcceleratorApi } from './kbAccelerator'
export { scriptsApi } from './scripts'
export { integrationsApi, sessionPsaApi } from './integrations'

View File

@@ -0,0 +1,41 @@
import { apiClient } from './client'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult } from '@/types/integrations'
export const integrationsApi = {
getConnection: () =>
apiClient.get<PsaConnectionResponse | null>('/integrations/psa/connections').then(r => r.data),
createConnection: (data: PsaConnectionCreate) =>
apiClient.post<PsaConnectionResponse>('/integrations/psa/connections', data).then(r => r.data),
updateConnection: (id: string, data: PsaConnectionUpdate) =>
apiClient.put<PsaConnectionResponse>(`/integrations/psa/connections/${id}`, data).then(r => r.data),
deleteConnection: (id: string) =>
apiClient.delete(`/integrations/psa/connections/${id}`),
testConnection: (id: string) =>
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
getTicket: (id: string) =>
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
getTicketStatuses: (ticketId: string) =>
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data),
listMembers: () =>
apiClient.get<PsaMemberResponse[]>('/integrations/psa/members').then(r => r.data),
getMemberMappings: () =>
apiClient.get<PsaMemberMappingResponse[]>('/integrations/psa/member-mappings').then(r => r.data),
saveMemberMappings: (mappings: { user_id: string; external_member_id: string; external_member_name: string }[]) =>
apiClient.post<PsaMemberMappingResponse[]>('/integrations/psa/member-mappings', mappings).then(r => r.data),
autoMatchMembers: () =>
apiClient.post<AutoMatchResult>('/integrations/psa/member-mappings/auto-match').then(r => r.data),
}
export const sessionPsaApi = {
linkTicket: (sessionId: string, psaTicketId: string | null) =>
apiClient.patch<TicketLinkResponse>(`/sessions/${sessionId}/ticket-link`, { psa_ticket_id: psaTicketId }).then(r => r.data),
getPostPreview: (sessionId: string) =>
apiClient.get<PsaPreviewResponse>(`/sessions/${sessionId}/psa-post/preview`).then(r => r.data),
postToTicket: (sessionId: string, data: { note_type: string; content: string; update_status_id?: number }) =>
apiClient.post<PsaPostResponse>(`/sessions/${sessionId}/psa-post`, data).then(r => r.data),
getPostHistory: (sessionId: string) =>
apiClient.get<PsaPostLogEntry[]>(`/sessions/${sessionId}/psa-posts`).then(r => r.data),
}

View File

@@ -0,0 +1,91 @@
import { Ticket, Unlink, Link2, Send } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Session } from '@/types'
import type { PSATicketInfo } from '@/types/integrations'
interface Props {
session: Session
hasConnection: boolean
onLinkClick: () => void
onUnlink: () => void
onUpdateClick?: () => void
ticketInfo?: PSATicketInfo | null
}
export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, onUpdateClick, ticketInfo }: Props) {
// No connection — show nothing
if (!hasConnection) return null
// No ticket linked — show subtle "Link Ticket" button
if (!session.psa_ticket_id) {
return (
<button
type="button"
onClick={onLinkClick}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground'
)}
>
<Link2 className="h-3.5 w-3.5" />
Link Ticket
</button>
)
}
// Ticket linked
return (
<div className="glass-card-static inline-flex items-start gap-2.5 rounded-lg border border-border px-3 py-2">
<Ticket className="mt-0.5 h-4 w-4 shrink-0 text-cyan-400" />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
CW #{session.psa_ticket_id}
{ticketInfo?.summary && (
<span className="text-muted-foreground"> {ticketInfo.summary}</span>
)}
</p>
{ticketInfo && (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{ticketInfo.company_name && <span>{ticketInfo.company_name}</span>}
{ticketInfo.board_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{ticketInfo.board_name}</span>
</>
)}
{ticketInfo.status_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{ticketInfo.status_name}</span>
</>
)}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{onUpdateClick && (
<button
type="button"
onClick={onUpdateClick}
className={cn(
'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors',
'hover:bg-primary/10 hover:text-foreground'
)}
title="Update ticket"
>
<Send className="h-3 w-3" />
<span className="hidden sm:inline">Update</span>
</button>
)}
<button
type="button"
onClick={onUnlink}
className="shrink-0 rounded p-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
title="Unlink ticket"
>
<Unlink className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,436 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Ticket, Search, AlertCircle, CheckCircle2, Hash, Loader2 } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
import type { PSATicketInfo, PSATicketSearchResult } from '@/types/integrations'
type Mode = 'search' | 'manual'
interface Props {
open: boolean
onClose: () => void
sessionId: string
onLinked: (ticketId: string, ticket: PSATicketInfo) => void
}
export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) {
const [mode, setMode] = useState<Mode>('search')
// Search mode state
const [searchQuery, setSearchQuery] = useState('')
const [includeClosed, setIncludeClosed] = useState(false)
const [searchResults, setSearchResults] = useState<PSATicketSearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
// Manual mode state
const [manualId, setManualId] = useState('')
// Shared state
const [selectedTicket, setSelectedTicket] = useState<PSATicketInfo | null>(null)
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
const [isLooking, setIsLooking] = useState(false)
const [isLinking, setIsLinking] = useState(false)
const [error, setError] = useState<string | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Debounced search
const performSearch = useCallback(async (query: string, closed: boolean) => {
if (!query.trim()) {
setSearchResults([])
setHasSearched(false)
return
}
setIsSearching(true)
setError(null)
try {
const results = await integrationsApi.searchTickets({
query: query.trim(),
include_closed: closed,
})
setSearchResults(results)
setHasSearched(true)
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(message || 'Failed to search tickets.')
setSearchResults([])
setHasSearched(true)
} finally {
setIsSearching(false)
}
}, [])
// Trigger debounced search when query or includeClosed changes
useEffect(() => {
if (mode !== 'search') return
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
if (!searchQuery.trim()) {
setSearchResults([])
setHasSearched(false)
return
}
debounceRef.current = setTimeout(() => {
performSearch(searchQuery, includeClosed)
}, 300)
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
}
}, [searchQuery, includeClosed, mode, performSearch])
const handleSelectSearchResult = async (result: PSATicketSearchResult) => {
setIsLooking(true)
setError(null)
try {
const ticket = await integrationsApi.getTicket(result.id)
setSelectedTicket(ticket)
setSelectedTicketId(result.id)
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(message || 'Failed to load ticket details.')
} finally {
setIsLooking(false)
}
}
const handleManualLookup = async () => {
const trimmed = manualId.trim()
if (!trimmed) return
setIsLooking(true)
setError(null)
setSelectedTicket(null)
setSelectedTicketId(null)
try {
const ticket = await integrationsApi.getTicket(trimmed)
setSelectedTicket(ticket)
setSelectedTicketId(trimmed)
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(message || 'Ticket not found. Please check the ticket number and try again.')
} finally {
setIsLooking(false)
}
}
const handleLink = async () => {
if (!selectedTicket || !selectedTicketId) return
setIsLinking(true)
setError(null)
try {
await sessionPsaApi.linkTicket(sessionId, selectedTicketId)
onLinked(selectedTicketId, selectedTicket)
handleReset()
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(message || 'Failed to link ticket.')
} finally {
setIsLinking(false)
}
}
const handleReset = () => {
setSearchQuery('')
setSearchResults([])
setHasSearched(false)
setManualId('')
setSelectedTicket(null)
setSelectedTicketId(null)
setError(null)
setIsLooking(false)
setIsLinking(false)
setIsSearching(false)
setIncludeClosed(false)
}
const handleClose = () => {
handleReset()
setMode('search')
onClose()
}
const handleClearSelection = () => {
setSelectedTicket(null)
setSelectedTicketId(null)
setError(null)
}
const handleManualKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && manualId.trim() && !isLooking && !selectedTicket) {
handleManualLookup()
}
}
const switchMode = (newMode: Mode) => {
setMode(newMode)
setSelectedTicket(null)
setSelectedTicketId(null)
setError(null)
}
return (
<Modal isOpen={open} onClose={handleClose} title="Link ConnectWise Ticket" size="sm">
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex gap-1 rounded-lg bg-white/[0.03] p-1">
<button
type="button"
onClick={() => switchMode('search')}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-all',
mode === 'search'
? 'bg-white/[0.08] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Search className="h-3.5 w-3.5" />
Search
</button>
<button
type="button"
onClick={() => switchMode('manual')}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-all',
mode === 'manual'
? 'bg-white/[0.08] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Hash className="h-3.5 w-3.5" />
Ticket #
</button>
</div>
{/* Search mode */}
{mode === 'search' && !selectedTicket && (
<div className="space-y-3">
<div>
<Input
type="text"
placeholder="Search tickets by summary, company, or #..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isLooking}
className="w-full"
autoFocus
/>
</div>
{/* Include closed toggle */}
<label className="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={includeClosed}
onChange={(e) => setIncludeClosed(e.target.checked)}
className="rounded border-border bg-card accent-primary"
/>
Include closed tickets
</label>
{/* Search results */}
{isSearching && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!isSearching && hasSearched && searchResults.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
No tickets found
</div>
)}
{!isSearching && searchResults.length > 0 && (
<div className="max-h-64 space-y-1 overflow-y-auto">
{searchResults.map((result) => (
<button
key={result.id}
type="button"
onClick={() => handleSelectSearchResult(result)}
disabled={isLooking}
className={cn(
'w-full rounded-lg border border-transparent px-3 py-2.5 text-left transition-all',
'hover:border-border hover:bg-white/[0.04]',
'disabled:opacity-50',
result.closed && 'opacity-60'
)}
>
<p className="truncate text-sm font-medium text-foreground">
<span className="text-muted-foreground">#{result.id}</span>
{' — '}
{result.summary}
</p>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{result.company_name && <span>{result.company_name}</span>}
{result.board_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{result.board_name}</span>
</>
)}
{result.status_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span className={result.closed ? 'text-[#5a6170]' : ''}>
{result.status_name}
</span>
</>
)}
</div>
</button>
))}
</div>
)}
{isLooking && (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading ticket details...</span>
</div>
)}
</div>
)}
{/* Manual mode */}
{mode === 'manual' && !selectedTicket && (
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">
Ticket Number
</label>
<div className="flex gap-2">
<Input
type="text"
inputMode="numeric"
placeholder="Enter ticket number..."
value={manualId}
onChange={(e) => {
setManualId(e.target.value)
if (error) setError(null)
}}
onKeyDown={handleManualKeyDown}
disabled={isLooking}
className="flex-1"
autoFocus
/>
<Button
variant="secondary"
size="md"
onClick={handleManualLookup}
disabled={!manualId.trim() || isLooking}
loading={isLooking}
>
{!isLooking && <Search className="h-4 w-4" />}
Look Up
</Button>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-start gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-3 py-2.5">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Selected ticket confirmation card */}
{selectedTicket && selectedTicketId && (
<div className="glass-card-static space-y-3 rounded-xl border border-border p-4">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-400" />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
CW #{selectedTicketId} {selectedTicket.summary}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{selectedTicket.company_name && (
<span>{selectedTicket.company_name}</span>
)}
{selectedTicket.board_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{selectedTicket.board_name}</span>
</>
)}
{selectedTicket.status_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{selectedTicket.status_name}</span>
</>
)}
{selectedTicket.priority_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{selectedTicket.priority_name}</span>
</>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="md"
onClick={handleClearSelection}
disabled={isLinking}
className="flex-1"
>
Back
</Button>
<Button
className="flex-1"
onClick={handleLink}
loading={isLinking}
>
<Ticket className="h-4 w-4" />
Link This Ticket
</Button>
</div>
</div>
)}
{/* Skip link */}
<div className="flex justify-center pt-1">
<button
type="button"
onClick={handleClose}
className={cn(
'text-sm text-muted-foreground transition-colors',
'hover:text-foreground'
)}
>
Skip
</button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,313 @@
import { useState, useEffect } from 'react'
import { Loader2, Copy, AlertTriangle, AlertCircle, RefreshCw } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { Textarea } from '@/components/ui/Textarea'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { sessionPsaApi } from '@/api/integrations'
import type { PsaPreviewResponse } from '@/types/integrations'
interface Props {
open: boolean
onClose: () => void
sessionId: string
onPosted?: () => void
}
type NoteType = 'internal_analysis' | 'resolution' | 'description'
const NOTE_TYPE_OPTIONS: { value: NoteType; label: string; description: string; warning?: string }[] = [
{ value: 'internal_analysis', label: 'Internal Analysis', description: 'Internal only, no notifications' },
{ value: 'resolution', label: 'Resolution', description: 'Internal only, triggers notifications' },
{ value: 'description', label: 'Description', description: 'Visible to the customer', warning: 'This note will be visible to the customer' },
]
const CHAR_WARNING_THRESHOLD = 15000
export function UpdateTicketModal({ open, onClose, sessionId, onPosted }: Props) {
const [preview, setPreview] = useState<PsaPreviewResponse | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [content, setContent] = useState('')
const [noteType, setNoteType] = useState<NoteType>('internal_analysis')
const [selectedStatusId, setSelectedStatusId] = useState<number | null>(null)
const [isPosting, setIsPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
useEffect(() => {
if (open) {
loadPreview()
} else {
// Reset state when closed
setPreview(null)
setContent('')
setNoteType('internal_analysis')
setSelectedStatusId(null)
setPostError(null)
setLoadError(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, sessionId])
const loadPreview = async () => {
setIsLoading(true)
setLoadError(null)
try {
const data = await sessionPsaApi.getPostPreview(sessionId)
setPreview(data)
setContent(data.content)
// Find current status ID from available_statuses matching ticket status_name
const currentStatus = data.available_statuses.find(
(s) => s.name === data.ticket.status_name
)
setSelectedStatusId(currentStatus?.id ?? null)
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setLoadError(axiosErr.response?.data?.detail || 'Failed to load preview')
console.error(err)
} finally {
setIsLoading(false)
}
}
const handleCopyContent = async () => {
try {
await navigator.clipboard.writeText(content)
toast.success('Content copied to clipboard')
} catch {
toast.error('Failed to copy content')
}
}
const handlePost = async () => {
setIsPosting(true)
setPostError(null)
try {
// Determine if status should be updated
const currentStatus = preview?.available_statuses.find(
(s) => s.name === preview?.ticket.status_name
)
const statusChanged = selectedStatusId !== null && selectedStatusId !== currentStatus?.id
await sessionPsaApi.postToTicket(sessionId, {
note_type: noteType,
content,
...(statusChanged ? { update_status_id: selectedStatusId } : {}),
})
toast.success('Note posted to ticket successfully')
onPosted?.()
onClose()
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setPostError(axiosErr.response?.data?.detail || 'Failed to post to ticket')
console.error(err)
} finally {
setIsPosting(false)
}
}
const currentStatusId = preview?.available_statuses.find(
(s) => s.name === preview?.ticket.status_name
)?.id
const footer = (
<div className="space-y-3">
{postError && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-red-400/20 bg-red-400/10 px-3 py-2 text-sm text-red-400">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{postError}</span>
</div>
<button
onClick={handlePost}
className="inline-flex items-center gap-1.5 shrink-0 text-xs font-medium hover:text-red-300 transition-colors"
>
<RefreshCw className="h-3 w-3" />
Retry
</button>
</div>
)}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handlePost}
disabled={isPosting || !content.trim()}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isPosting && <Loader2 className="h-4 w-4 animate-spin" />}
Update Ticket
</button>
</div>
</div>
)
return (
<Modal isOpen={open} onClose={onClose} title="Update Ticket" size="xl" footer={footer}>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{loadError && (
<div className="flex flex-col items-center gap-3 py-8">
<div className="flex items-center gap-2 text-red-400">
<AlertCircle className="h-5 w-5" />
<span className="text-sm">{loadError}</span>
</div>
<button
onClick={loadPreview}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Try again
</button>
</div>
)}
{preview && !isLoading && (
<div className="grid gap-6 lg:grid-cols-2">
{/* Left panel - Content editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Note Content
</p>
<button
type="button"
onClick={handleCopyContent}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Copy content"
>
<Copy className="h-3 w-3" />
Copy
</button>
</div>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={18}
className="min-h-[300px] resize-y font-mono text-xs"
/>
<div className="flex items-center justify-between text-xs">
{content.length >= CHAR_WARNING_THRESHOLD ? (
<span className="flex items-center gap-1 text-amber-400">
<AlertTriangle className="h-3 w-3" />
Content may be truncated by ConnectWise
</span>
) : (
<span />
)}
<span className="text-muted-foreground">
{content.length.toLocaleString()} characters
</span>
</div>
</div>
{/* Right panel - Controls */}
<div className="space-y-6">
{/* Ticket info summary */}
<div className="glass-card-static rounded-lg border border-border p-3">
<p className="text-sm font-medium text-foreground">
CW #{preview.ticket.id}
{preview.ticket.summary && (
<span className="text-muted-foreground"> {preview.ticket.summary}</span>
)}
</p>
{(preview.ticket.company_name || preview.ticket.board_name) && (
<p className="mt-1 text-xs text-muted-foreground">
{[preview.ticket.company_name, preview.ticket.board_name].filter(Boolean).join(' / ')}
</p>
)}
</div>
{/* Note Type */}
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
Note Type
</p>
<div className="space-y-2">
{NOTE_TYPE_OPTIONS.map((opt) => (
<label
key={opt.value}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 transition-colors',
noteType === opt.value
? 'border-primary/30 bg-primary/5'
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
)}
>
<input
type="radio"
name="note_type"
value={opt.value}
checked={noteType === opt.value}
onChange={() => setNoteType(opt.value)}
className="mt-0.5 accent-cyan-400"
/>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">{opt.label}</span>
<p className="text-xs text-muted-foreground">{opt.description}</p>
{opt.warning && noteType === opt.value && (
<p className="mt-1 flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle className="h-3 w-3 shrink-0" />
{opt.warning}
</p>
)}
</div>
</label>
))}
</div>
</div>
{/* Ticket Status */}
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-2">
Ticket Status
</p>
<select
value={selectedStatusId ?? ''}
onChange={(e) => setSelectedStatusId(e.target.value ? Number(e.target.value) : null)}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
>
{preview.available_statuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
{status.id === currentStatusId ? ' (current)' : ''}
{status.is_closed ? ' [closed]' : ''}
</option>
))}
</select>
</div>
{/* Previous posts */}
{preview.previous_posts > 0 && (
<p className="text-xs text-muted-foreground">
This session has been posted {preview.previous_posts} time{preview.previous_posts > 1 ? 's' : ''} before.
</p>
)}
</div>
</div>
)}
</Modal>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
@@ -555,6 +555,23 @@ export function AccountSettingsPage() {
</Link>
)}
{/* Integrations Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/integrations"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Plug className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Integrations</h2>
<p className="text-sm text-muted-foreground">Connect your PSA to sync session documentation to tickets</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Feedback Link (all users) */}
<Link
to="/feedback"

View File

@@ -24,6 +24,11 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
import { UpdateTicketModal } from '@/components/session/UpdateTicketModal'
import type { PSATicketInfo } from '@/types/integrations'
interface StepState {
notes: string
@@ -86,6 +91,12 @@ export function ProceduralNavigationPage() {
const [isSavingStep, setIsSavingStep] = useState(false)
const [copilotOpen, setCopilotOpen] = useState(false)
// PSA ticket link state
const [hasConnection, setHasConnection] = useState(false)
const [showTicketPicker, setShowTicketPicker] = useState(false)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [psaTicketInfo, setPsaTicketInfo] = useState<PSATicketInfo | null>(null)
// Editable variables panel state
const [editingVarName, setEditingVarName] = useState<string | null>(null)
const [editingVarValue, setEditingVarValue] = useState('')
@@ -131,6 +142,32 @@ export function ProceduralNavigationPage() {
}
}, [treeId])
// Check for PSA connection on mount
useEffect(() => {
integrationsApi.getConnection()
.then((conn) => setHasConnection(!!conn))
.catch(() => setHasConnection(false))
}, [])
const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => {
setPsaTicketInfo(ticket)
setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev)
setShowTicketPicker(false)
toast.success(`Linked to CW #${linkedTicketId}`)
}
const handleTicketUnlink = async () => {
if (!session) return
try {
await sessionPsaApi.linkTicket(session.id, null)
setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev)
setPsaTicketInfo(null)
toast.success('Ticket unlinked')
} catch {
toast.error('Failed to unlink ticket')
}
}
// Parse backend timestamp — ensure UTC if no timezone info
const parseTimestamp = (ts: string) => {
if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) {
@@ -584,6 +621,18 @@ export function ProceduralNavigationPage() {
Exit
</button>
</div>
{session && (
<div className="mt-1.5">
<TicketLinkIndicator
session={session}
hasConnection={hasConnection}
onLinkClick={() => setShowTicketPicker(true)}
onUnlink={handleTicketUnlink}
onUpdateClick={session.psa_ticket_id ? () => setShowUpdateModal(true) : undefined}
ticketInfo={psaTicketInfo}
/>
</div>
)}
<div className="mt-2">
<ProgressBar
currentStep={completedStepIds.size}
@@ -877,6 +926,23 @@ export function ProceduralNavigationPage() {
{treeId && (
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
)}
{/* Ticket Picker Modal */}
{session && (
<TicketPickerModal
open={showTicketPicker}
onClose={() => setShowTicketPicker(false)}
sessionId={session.id}
onLinked={handleTicketLinked}
/>
)}
{session && (
<UpdateTicketModal
open={showUpdateModal}
onClose={() => setShowUpdateModal(false)}
sessionId={session.id}
/>
)}
</div>
)
}

View File

@@ -22,6 +22,11 @@ import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sess
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
import { Button } from '@/components/ui/Button'
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
import { UpdateTicketModal } from '@/components/session/UpdateTicketModal'
import type { PSATicketInfo } from '@/types/integrations'
interface LocationState {
sessionId?: string
@@ -65,6 +70,12 @@ export function TreeNavigationPage() {
const sharePopoverRef = useRef<HTMLDivElement>(null)
const [copilotOpen, setCopilotOpen] = useState(false)
// PSA ticket link state
const [hasConnection, setHasConnection] = useState(false)
const [showTicketPicker, setShowTicketPicker] = useState(false)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [ticketInfo, setTicketInfo] = useState<PSATicketInfo | null>(null)
const handleCopyCommand = (text: string) => {
navigator.clipboard.writeText(text)
setCopiedCommand(text)
@@ -272,6 +283,32 @@ export function TreeNavigationPage() {
}
}, [treeId])
// Check for PSA connection on mount
useEffect(() => {
integrationsApi.getConnection()
.then((conn) => setHasConnection(!!conn))
.catch(() => setHasConnection(false))
}, [])
const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => {
setTicketInfo(ticket)
setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev)
setShowTicketPicker(false)
toast.success(`Linked to CW #${linkedTicketId}`)
}
const handleTicketUnlink = async () => {
if (!session) return
try {
await sessionPsaApi.linkTicket(session.id, null)
setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev)
setTicketInfo(null)
toast.success('Ticket unlinked')
} catch {
toast.error('Failed to unlink ticket')
}
}
const loadTreeAndSession = async () => {
setIsLoading(true)
setError(null)
@@ -656,6 +693,16 @@ export function TreeNavigationPage() {
{clientName && `Client: ${clientName}`}
</p>
)}
{session && (
<TicketLinkIndicator
session={session}
hasConnection={hasConnection}
onLinkClick={() => setShowTicketPicker(true)}
onUnlink={handleTicketUnlink}
onUpdateClick={session.psa_ticket_id ? () => setShowUpdateModal(true) : undefined}
ticketInfo={ticketInfo}
/>
)}
</div>
<div className="flex items-center gap-2">
{/* Share Progress Popover */}
@@ -1251,6 +1298,23 @@ export function TreeNavigationPage() {
onClose={() => setShowShareModal(false)}
/>
)}
{/* Ticket Picker Modal */}
{session && (
<TicketPickerModal
open={showTicketPicker}
onClose={() => setShowTicketPicker(false)}
sessionId={session.id}
onLinked={handleTicketLinked}
/>
)}
{session && (
<UpdateTicketModal
open={showUpdateModal}
onClose={() => setShowUpdateModal(false)}
sessionId={session.id}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,799 @@
import { useEffect, useState } from 'react'
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { integrationsApi } from '@/api/integrations'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
import type { PsaMemberResponse, PsaMemberMappingResponse } from '@/types/integrations'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) return `${diffDays}d ago`
return date.toLocaleDateString()
}
interface ConnectionForm {
display_name: string
site_url: string
company_id: string
public_key: string
private_key: string
}
const emptyForm: ConnectionForm = {
display_name: '',
site_url: '',
company_id: '',
public_key: '',
private_key: '',
}
type Tab = 'connection' | 'member-mapping' | 'post-history'
export function IntegrationsPage() {
const [activeTab, setActiveTab] = useState<Tab>('connection')
const [connection, setConnection] = useState<PsaConnectionResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Form state
const [mode, setMode] = useState<'view' | 'setup' | 'edit'>('setup')
const [form, setForm] = useState<ConnectionForm>(emptyForm)
const [isSaving, setIsSaving] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
// Test state
const [isTesting, setIsTesting] = useState(false)
const [testResult, setTestResult] = useState<PsaConnectionTestResponse | null>(null)
// Delete state
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
useEffect(() => {
loadConnection()
}, [])
const loadConnection = async () => {
setIsLoading(true)
setError(null)
try {
const data = await integrationsApi.getConnection()
setConnection(data)
setMode(data ? 'view' : 'setup')
} catch (err) {
// 404 means no connection exists — that's fine
const axiosErr = err as { response?: { status?: number } }
if (axiosErr.response?.status === 404) {
setConnection(null)
setMode('setup')
} else {
setError('Failed to load integration settings')
console.error(err)
}
} finally {
setIsLoading(false)
}
}
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setIsSaving(true)
setFormError(null)
try {
const payload: PsaConnectionCreate = {
provider: 'connectwise',
...form,
}
const created = await integrationsApi.createConnection(payload)
setConnection(created)
setMode('view')
setForm(emptyForm)
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setFormError(axiosErr.response?.data?.detail || 'Failed to create connection. Please check your credentials and try again.')
console.error(err)
} finally {
setIsSaving(false)
}
}
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault()
if (!connection) return
setIsSaving(true)
setFormError(null)
try {
const update: PsaConnectionUpdate = {}
if (form.display_name && form.display_name !== connection.display_name) update.display_name = form.display_name
if (form.site_url && form.site_url !== connection.site_url) update.site_url = form.site_url
if (form.company_id && form.company_id !== connection.company_id) update.company_id = form.company_id
if (form.public_key) update.public_key = form.public_key
if (form.private_key) update.private_key = form.private_key
// client_id is server-side (settings.CW_CLIENT_ID), not per-account
const updated = await integrationsApi.updateConnection(connection.id, update)
setConnection(updated)
setMode('view')
setForm(emptyForm)
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setFormError(axiosErr.response?.data?.detail || 'Failed to update connection.')
console.error(err)
} finally {
setIsSaving(false)
}
}
const handleTest = async () => {
if (!connection) return
setIsTesting(true)
setTestResult(null)
try {
const result = await integrationsApi.testConnection(connection.id)
setTestResult(result)
} catch (err) {
setTestResult({ success: false, message: 'Connection test failed. Check your credentials.', server_version: null })
console.error(err)
} finally {
setIsTesting(false)
}
}
const handleDelete = async () => {
if (!connection) return
setIsDeleting(true)
try {
await integrationsApi.deleteConnection(connection.id)
setConnection(null)
setMode('setup')
setForm(emptyForm)
setShowDeleteConfirm(false)
setTestResult(null)
} catch (err) {
console.error('Failed to delete connection:', err)
} finally {
setIsDeleting(false)
}
}
const startEdit = () => {
if (!connection) return
setForm({
display_name: connection.display_name,
site_url: connection.site_url,
company_id: connection.company_id,
public_key: '',
private_key: '',
})
setFormError(null)
setTestResult(null)
setMode('edit')
}
const cancelEdit = () => {
setMode('view')
setForm(emptyForm)
setFormError(null)
}
if (isLoading) {
return (
<>
<PageMeta title="Integrations" />
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
)
}
if (error) {
return (
<>
<PageMeta title="Integrations" />
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
</div>
</div>
</>
)
}
return (
<>
<PageMeta title="Integrations" />
<div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Plug className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Integrations</h1>
</div>
<p className="mt-2 text-muted-foreground">
Connect your PSA to post session documentation directly to tickets.
</p>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-1 border-b border-border">
{([
{ id: 'connection' as Tab, label: 'Connection', icon: Plug },
{ id: 'member-mapping' as Tab, label: 'Member Mapping', icon: Users },
{ id: 'post-history' as Tab, label: 'Post History', icon: History },
]).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={cn(
'inline-flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
activeTab === id
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{/* Connection Tab */}
{activeTab === 'connection' && (
<div className="max-w-3xl">
{/* Setup / Edit Form */}
{(mode === 'setup' || mode === 'edit') && (
<div className="glass-card-static p-6">
<div className="flex items-center gap-2 mb-6">
<Shield className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">
{mode === 'setup' ? 'Connect to ConnectWise PSA' : 'Edit Connection'}
</h2>
</div>
<form onSubmit={mode === 'setup' ? handleCreate : handleUpdate} className="space-y-4">
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Display Name
</label>
<Input
type="text"
value={form.display_name}
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
placeholder="My ConnectWise Instance"
required={mode === 'setup'}
className="mt-1"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Site URL
</label>
<Input
type="text"
value={form.site_url}
onChange={(e) => setForm({ ...form, site_url: e.target.value })}
placeholder="na.myconnectwise.net"
required={mode === 'setup'}
className="mt-1"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Company ID
</label>
<Input
type="text"
value={form.company_id}
onChange={(e) => setForm({ ...form, company_id: e.target.value })}
placeholder="mycompany"
required={mode === 'setup'}
className="mt-1"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Public Key
</label>
<Input
type="text"
value={form.public_key}
onChange={(e) => setForm({ ...form, public_key: e.target.value })}
placeholder={mode === 'edit' && connection ? `Current: ${connection.public_key_hint}` : 'Enter public key'}
required={mode === 'setup'}
className="mt-1"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Private Key
</label>
<Input
type="password"
value={form.private_key}
onChange={(e) => setForm({ ...form, private_key: e.target.value })}
placeholder={mode === 'edit' && connection ? `Current: ${connection.private_key_hint}` : 'Enter private key'}
required={mode === 'setup'}
className="mt-1"
/>
</div>
{formError && (
<div className="flex items-center gap-2 rounded-md border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
<AlertCircle className="h-4 w-4 shrink-0" />
{formError}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={isSaving}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSaving && <Loader2 className="h-4 w-4 animate-spin" />}
{mode === 'setup' ? 'Connect' : 'Save Changes'}
</button>
{mode === 'edit' && (
<button
type="button"
onClick={cancelEdit}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
)}
</div>
</form>
</div>
)}
{/* Connected View */}
{mode === 'view' && connection && (
<div className="space-y-4">
{/* Status Card */}
<div className="glass-card-static p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-label font-medium text-primary">
ConnectWise
</span>
<h2 className="text-lg font-semibold text-foreground">{connection.display_name}</h2>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex h-2 w-2 rounded-full',
connection.is_active ? 'bg-emerald-400' : 'bg-amber-400'
)}
/>
<span className={cn(
'text-xs font-label',
connection.is_active ? 'text-emerald-400' : 'text-amber-400'
)}>
{connection.is_active ? 'Connected' : 'Not validated'}
</span>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Site URL</p>
<p className="mt-1 text-sm text-foreground">{connection.site_url}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Company ID</p>
<p className="mt-1 text-sm text-foreground">{connection.company_id}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Public Key</p>
<p className="mt-1 text-sm text-foreground font-mono">{connection.public_key_hint}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Private Key</p>
<p className="mt-1 text-sm text-foreground font-mono">{connection.private_key_hint}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Last Validated</p>
<p className="mt-1 text-sm text-foreground">
{connection.last_validated_at ? formatRelativeTime(connection.last_validated_at) : 'Never'}
</p>
</div>
</div>
</div>
{/* Test Result */}
{testResult && (
<div
className={cn(
'flex items-center gap-2 rounded-xl border p-4 text-sm',
testResult.success
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-400'
: 'border-red-400/20 bg-red-400/10 text-red-400'
)}
>
{testResult.success ? (
<CheckCircle2 className="h-4 w-4 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 shrink-0" />
)}
<span>{testResult.message}</span>
{testResult.server_version && (
<span className="ml-auto text-xs text-muted-foreground">
v{testResult.server_version}
</span>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3">
<button
onClick={handleTest}
disabled={isTesting}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
Test Connection
</button>
<button
onClick={startEdit}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all'
)}
>
<Pencil className="h-4 w-4" />
Edit
</button>
{showDeleteConfirm ? (
<div className="flex items-center gap-2 ml-auto">
<span className="text-sm text-muted-foreground">Disconnect?</span>
<button
onClick={handleDelete}
disabled={isDeleting}
className="inline-flex items-center gap-1.5 rounded-[10px] px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-400/10 transition-colors"
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Confirm
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="inline-flex items-center gap-2 ml-auto text-sm text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 className="h-4 w-4" />
Disconnect
</button>
)}
</div>
</div>
)}
</div>
)}
{/* Member Mapping Tab */}
{activeTab === 'member-mapping' && (
<MemberMappingTab connection={connection} />
)}
{/* Post History Tab */}
{activeTab === 'post-history' && (
<div className="max-w-3xl">
<div className="glass-card-static p-6">
<div className="flex items-center gap-3 mb-4">
<Ticket className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Post History</h2>
</div>
<p className="text-sm text-muted-foreground">
View post history from individual sessions by clicking on linked tickets.
When a session has a ConnectWise ticket linked, use the Update button to post
session documentation and view previous posts.
</p>
</div>
</div>
)}
</div>
</>
)
}
/* ─── Member Mapping Tab ─── */
function MemberMappingTab({ connection }: { connection: PsaConnectionResponse | null }) {
const [cwMembers, setCwMembers] = useState<PsaMemberResponse[]>([])
const [mappings, setMappings] = useState<PsaMemberMappingResponse[]>([])
const [localMappings, setLocalMappings] = useState<Record<string, { external_member_id: string; external_member_name: string }>>({})
const [isLoadingData, setIsLoadingData] = useState(false)
const [isAutoMatching, setIsAutoMatching] = useState(false)
const [isSavingMappings, setIsSavingMappings] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const [hasLoaded, setHasLoaded] = useState(false)
useEffect(() => {
if (connection) {
loadMappingData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection?.id])
const loadMappingData = async () => {
setIsLoadingData(true)
try {
const [members, existingMappings] = await Promise.all([
integrationsApi.listMembers(),
integrationsApi.getMemberMappings(),
])
setCwMembers(members)
setMappings(existingMappings)
// Build local mapping state from existing mappings
const lookup: Record<string, { external_member_id: string; external_member_name: string }> = {}
for (const m of existingMappings) {
lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name }
}
setLocalMappings(lookup)
setIsDirty(false)
setHasLoaded(true)
} catch (err) {
console.error('Failed to load mapping data:', err)
toast.error('Failed to load member data')
} finally {
setIsLoadingData(false)
}
}
const handleAutoMatch = async () => {
setIsAutoMatching(true)
try {
const result = await integrationsApi.autoMatchMembers()
toast.success(`Matched ${result.matched.length} user${result.matched.length !== 1 ? 's' : ''}${result.unmatched_users > 0 ? `, ${result.unmatched_users} remain unmapped` : ''}`)
await loadMappingData()
} catch (err) {
console.error('Auto-match failed:', err)
toast.error('Auto-match failed')
} finally {
setIsAutoMatching(false)
}
}
const handleMemberChange = (userId: string, externalMemberId: string) => {
setLocalMappings(prev => {
const next = { ...prev }
if (!externalMemberId) {
delete next[userId]
} else {
const member = cwMembers.find(m => m.id === externalMemberId)
next[userId] = {
external_member_id: externalMemberId,
external_member_name: member?.name || '',
}
}
return next
})
setIsDirty(true)
}
const handleSave = async () => {
setIsSavingMappings(true)
try {
const payload = Object.entries(localMappings).map(([user_id, mapping]) => ({
user_id,
external_member_id: mapping.external_member_id,
external_member_name: mapping.external_member_name,
}))
await integrationsApi.saveMemberMappings(payload)
toast.success('Member mappings saved')
setIsDirty(false)
// Reload to get fresh data with matched_by etc.
await loadMappingData()
} catch (err) {
console.error('Failed to save mappings:', err)
toast.error('Failed to save mappings')
} finally {
setIsSavingMappings(false)
}
}
// Derive user list from mappings response (all account users are returned)
const userRows = mappings.length > 0
? mappings.map(m => ({ user_id: m.user_id, user_email: m.user_email, user_name: m.user_name, matched_by: m.matched_by }))
: []
// Deduplicate: mappings may only contain mapped users, so we show what we have
const uniqueUsers = hasLoaded ? userRows : []
if (!connection) {
return (
<div className="max-w-3xl">
<div className="glass-card-static p-6">
<div className="flex items-center gap-3 mb-4">
<Users className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Member Mapping</h2>
</div>
<p className="text-sm text-muted-foreground">
Set up a PSA connection first to map team members to ConnectWise members.
</p>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-4">
{/* Header + Auto-Match */}
<div className="glass-card-static p-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Member Mapping</h2>
</div>
<button
type="button"
onClick={handleAutoMatch}
disabled={isAutoMatching || isLoadingData}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isAutoMatching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
Auto-Match by Email
</button>
</div>
<p className="text-sm text-muted-foreground">
Map your ResolutionFlow users to ConnectWise members so session posts are attributed correctly.
</p>
</div>
{/* Loading state */}
{isLoadingData && (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Mapping Table */}
{hasLoaded && !isLoadingData && (
<div className="glass-card-static overflow-hidden">
{uniqueUsers.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No users found. Use Auto-Match to discover and map users.
</div>
) : (
<>
{/* Table header */}
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 border-b border-border px-6 py-3">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">User</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Email</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Mapped To</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground w-20 text-center">Method</span>
</div>
{/* Rows */}
{uniqueUsers.map((user) => {
const currentMapping = localMappings[user.user_id]
return (
<div
key={user.user_id}
className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 items-center border-b border-border/50 px-6 py-3 last:border-b-0"
>
<span className="text-sm text-foreground truncate">{user.user_name}</span>
<span className="text-sm text-muted-foreground truncate">{user.user_email}</span>
<select
title={`Map ${user.user_name} to a ConnectWise member`}
value={currentMapping?.external_member_id || ''}
onChange={(e) => handleMemberChange(user.user_id, e.target.value)}
className={cn(
'w-full rounded-lg border bg-card px-3 py-1.5 text-sm text-foreground',
'border-border focus:border-[rgba(6,182,212,0.3)] focus:outline-none',
!currentMapping && 'text-muted-foreground'
)}
>
<option value="">-- Unmapped --</option>
{cwMembers.map((member) => (
<option key={member.id} value={member.id}>
{member.name}{member.email ? ` (${member.email})` : ''}
</option>
))}
</select>
<span className="w-20 text-center">
{currentMapping && !isDirty && user.matched_by ? (
<span className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.625rem] font-label',
user.matched_by === 'auto_email'
? 'bg-primary/10 text-primary'
: 'bg-card border border-border text-muted-foreground'
)}>
{user.matched_by === 'auto_email' ? 'auto' : 'manual'}
</span>
) : currentMapping ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.625rem] font-label bg-card border border-border text-muted-foreground">
manual
</span>
) : (
<span className="text-[0.625rem] text-muted-foreground/50"></span>
)}
</span>
</div>
)
})}
</>
)}
</div>
)}
{/* Save button */}
{isDirty && (
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={isSavingMappings}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSavingMappings ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Mappings
</button>
</div>
)}
</div>
)
}
export default IntegrationsPage

View File

@@ -68,6 +68,7 @@ const ProfileSettingsPage = lazy(() => import('@/pages/account/ProfileSettingsPa
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
const IntegrationsPage = lazy(() => import('@/pages/account/IntegrationsPage'))
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
@@ -224,6 +225,14 @@ export const router = sentryCreateBrowserRouter([
),
},
{ path: 'target-lists', element: page(TargetListsPage) },
{
path: 'integrations',
element: (
<ProtectedRoute requiredRole="owner">
{page(IntegrationsPage)}
</ProtectedRoute>
),
},
],
},
],

View File

@@ -89,3 +89,4 @@ export type {
} from './kbAccelerator'
export * from './scripts'
export * from './integrations'

View File

@@ -0,0 +1,123 @@
export interface PsaConnectionResponse {
id: string
account_id: string
provider: string
display_name: string
site_url: string
company_id: string
is_active: boolean
last_validated_at: string | null
created_at: string
updated_at: string
public_key_hint: string
private_key_hint: string
}
export interface PsaConnectionCreate {
provider: string
display_name: string
site_url: string
company_id: string
public_key: string
private_key: string
}
export interface PsaConnectionUpdate {
display_name?: string
site_url?: string
company_id?: string
public_key?: string
private_key?: string
}
export interface PsaConnectionTestResponse {
success: boolean
message: string
server_version: string | null
}
export interface PSATicketInfo {
id: string
summary: string
company_name: string | null
board_name: string | null
status_name: string | null
priority_name: string | null
}
export interface TicketLinkResponse {
session_id: string
psa_ticket_id: string | null
ticket: PSATicketInfo | null
}
export interface PSATicketSearchResult {
id: string
summary: string
company_name: string | null
board_name: string | null
status_name: string | null
priority_name: string | null
closed: boolean
}
export interface PSATicketStatusItem {
id: number
name: string
is_closed: boolean
}
export interface PsaPreviewResponse {
content: string
ticket: PSATicketSearchResult
available_statuses: PSATicketStatusItem[]
character_count: number
previous_posts: number
}
export interface PsaPostResponse {
id: string
session_id: string
ticket_id: string
note_type: string
status: string
external_note_id: string | null
error_message: string | null
status_changed_from: string | null
status_changed_to: string | null
posted_at: string
}
export interface PsaPostLogEntry {
id: string
ticket_id: string
note_type: string
status: string
error_message: string | null
status_changed_from: string | null
status_changed_to: string | null
posted_at: string
content_preview: string
}
export interface PsaMemberResponse {
id: string
identifier: string
name: string
email: string | null
}
export interface PsaMemberMappingResponse {
id: string
user_id: string
user_email: string
user_name: string
external_member_id: string
external_member_name: string
matched_by: string
}
export interface AutoMatchResult {
matched: PsaMemberMappingResponse[]
unmatched_users: number
}

View File

@@ -64,6 +64,8 @@ export interface Session {
assigned_to_id?: string | null
batch_id?: string
target_label?: string
psa_ticket_id?: string | null
psa_connection_id?: string | null
}
export interface SessionCreate {