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:
26
CLAUDE.md
26
CLAUDE.md
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
39
backend/alembic/versions/058_add_psa_connections.py
Normal file
39
backend/alembic/versions/058_add_psa_connections.py
Normal 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")
|
||||
@@ -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")
|
||||
57
backend/alembic/versions/060_add_psa_post_log.py
Normal file
57
backend/alembic/versions/060_add_psa_post_log.py
Normal 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")
|
||||
60
backend/alembic/versions/061_add_psa_member_mappings.py
Normal file
60
backend/alembic/versions/061_add_psa_member_mappings.py
Normal 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")
|
||||
565
backend/app/api/endpoints/integrations.py
Normal file
565
backend/app/api/endpoints/integrations.py
Normal 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
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
48
backend/app/models/psa_connection.py
Normal file
48
backend/app/models/psa_connection.py
Normal 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")
|
||||
47
backend/app/models/psa_member_mapping.py
Normal file
47
backend/app/models/psa_member_mapping.py
Normal 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])
|
||||
58
backend/app/models/psa_post_log.py
Normal file
58
backend/app/models/psa_post_log.py
Normal 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])
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
138
backend/app/schemas/psa_connection.py
Normal file
138
backend/app/schemas/psa_connection.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
1
backend/app/services/psa/__init__.py
Normal file
1
backend/app/services/psa/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""PSA integration abstraction layer."""
|
||||
68
backend/app/services/psa/base.py
Normal file
68
backend/app/services/psa/base.py
Normal 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]:
|
||||
...
|
||||
38
backend/app/services/psa/cache.py
Normal file
38
backend/app/services/psa/cache.py
Normal 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()
|
||||
1
backend/app/services/psa/connectwise/__init__.py
Normal file
1
backend/app/services/psa/connectwise/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""ConnectWise PSA provider implementation."""
|
||||
288
backend/app/services/psa/connectwise/client.py
Normal file
288
backend/app/services/psa/connectwise/client.py
Normal 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
|
||||
283
backend/app/services/psa/connectwise/provider.py
Normal file
283
backend/app/services/psa/connectwise/provider.py
Normal 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),
|
||||
)
|
||||
53
backend/app/services/psa/encryption.py
Normal file
53
backend/app/services/psa/encryption.py
Normal 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:]
|
||||
45
backend/app/services/psa/exceptions.py
Normal file
45
backend/app/services/psa/exceptions.py
Normal 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
|
||||
51
backend/app/services/psa/registry.py
Normal file
51
backend/app/services/psa/registry.py
Normal 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,
|
||||
)
|
||||
63
backend/app/services/psa/types.py
Normal file
63
backend/app/services/psa/types.py
Normal 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"
|
||||
59
backend/tests/test_psa_connections.py
Normal file
59
backend/tests/test_psa_connections.py
Normal 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
|
||||
44
backend/tests/test_psa_encryption.py
Normal file
44
backend/tests/test_psa_encryption.py
Normal 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
303215
docs/connectwise/All.json
Normal file
File diff suppressed because it is too large
Load Diff
267
docs/connectwise/CONNECTWISE-API-REFERENCE.md
Normal file
267
docs/connectwise/CONNECTWISE-API-REFERENCE.md
Normal 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
|
||||
1114
docs/connectwise/Developer-Guide.md
Normal file
1114
docs/connectwise/Developer-Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
381
docs/connectwise/best-practices/Bundled-Requests.md
Normal file
381
docs/connectwise/best-practices/Bundled-Requests.md
Normal 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 id’s 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`|`}`|
|
||||
|---|---|
|
||||
360
docs/connectwise/best-practices/PSA-API-Requests.md
Normal file
360
docs/connectwise/best-practices/PSA-API-Requests.md
Normal 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
|
||||
|
||||

|
||||
|
||||
|**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.|
|
||||
231
docs/connectwise/best-practices/PSA-Callbacks.md
Normal file
231
docs/connectwise/best-practices/PSA-Callbacks.md
Normal 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¶m2=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¶m2=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¶m2=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¶m2=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¶m2=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¶m2=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|
|
||||
51
docs/connectwise/best-practices/PSA-Cloud-URL-Formatting.md
Normal file
51
docs/connectwise/best-practices/PSA-Cloud-URL-Formatting.md
Normal 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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
_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.
|
||||
23
docs/connectwise/best-practices/PSA-Data-Protection.md
Normal file
23
docs/connectwise/best-practices/PSA-Data-Protection.md
Normal 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 don’t need access to tickets they haven’t created themselves. Integrations that impact agreements, don’t need to see additions that they don’t manage. By providing access to ALL, integrations may be reading data that you wouldn’t expect, or updating information that they don’t 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 don’t 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 hasn’t specifically given them API keys.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
70
docs/connectwise/best-practices/PSA-Markdown.md
Normal file
70
docs/connectwise/best-practices/PSA-Markdown.md
Normal 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))|
|
||||
122
docs/connectwise/best-practices/PSA-Pagination.md
Normal file
122
docs/connectwise/best-practices/PSA-Pagination.md
Normal 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 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.
|
||||
|
||||

|
||||
|
||||
> 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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
> 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 aren’t 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 don’t 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…
|
||||
48
docs/connectwise/best-practices/PSA-Service-Tickets.md
Normal file
48
docs/connectwise/best-practices/PSA-Service-Tickets.md
Normal 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.|
|
||||
50
docs/connectwise/best-practices/PSA-Versioning.md
Normal file
50
docs/connectwise/best-practices/PSA-Versioning.md
Normal 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.
|
||||
303215
docs/connectwise/connectwise-psa-openapi-full.json
Normal file
303215
docs/connectwise/connectwise-psa-openapi-full.json
Normal file
File diff suppressed because it is too large
Load Diff
111573
docs/connectwise/connectwise-psa-resolutionflow-reference.json
Normal file
111573
docs/connectwise/connectwise-psa-resolutionflow-reference.json
Normal file
File diff suppressed because it is too large
Load Diff
1482
docs/plans/2026-03-14-connectwise-psa-integration-plan.md
Normal file
1482
docs/plans/2026-03-14-connectwise-psa-integration-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
41
frontend/src/api/integrations.ts
Normal file
41
frontend/src/api/integrations.ts
Normal 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),
|
||||
}
|
||||
91
frontend/src/components/session/TicketLinkIndicator.tsx
Normal file
91
frontend/src/components/session/TicketLinkIndicator.tsx
Normal 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]">•</span>
|
||||
<span>{ticketInfo.board_name}</span>
|
||||
</>
|
||||
)}
|
||||
{ticketInfo.status_name && (
|
||||
<>
|
||||
<span className="text-[#5a6170]">•</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>
|
||||
)
|
||||
}
|
||||
436
frontend/src/components/session/TicketPickerModal.tsx
Normal file
436
frontend/src/components/session/TicketPickerModal.tsx
Normal 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]">•</span>
|
||||
<span>{result.board_name}</span>
|
||||
</>
|
||||
)}
|
||||
{result.status_name && (
|
||||
<>
|
||||
<span className="text-[#5a6170]">•</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]">•</span>
|
||||
<span>{selectedTicket.board_name}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedTicket.status_name && (
|
||||
<>
|
||||
<span className="text-[#5a6170]">•</span>
|
||||
<span>{selectedTicket.status_name}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedTicket.priority_name && (
|
||||
<>
|
||||
<span className="text-[#5a6170]">•</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>
|
||||
)
|
||||
}
|
||||
313
frontend/src/components/session/UpdateTicketModal.tsx
Normal file
313
frontend/src/components/session/UpdateTicketModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">→</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Feedback Link (all users) */}
|
||||
<Link
|
||||
to="/feedback"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
799
frontend/src/pages/account/IntegrationsPage.tsx
Normal file
799
frontend/src/pages/account/IntegrationsPage.tsx
Normal 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
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -89,3 +89,4 @@ export type {
|
||||
} from './kbAccelerator'
|
||||
|
||||
export * from './scripts'
|
||||
export * from './integrations'
|
||||
|
||||
123
frontend/src/types/integrations.ts
Normal file
123
frontend/src/types/integrations.ts
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user