ConnectWise PSA skills and tools under ops/connectwise (hub-sanitized 2026-11-01: client intel, internal workflow/credential/member/company ids, archived-page apiToken/sessionId stripped to placeholders)

This commit is contained in:
Hermes Agent committed 2026-11-01 13:34:58 -08:00
commit 9c96092034
36 files changed
+310875

No files matched your search

@@ -0,0 +1,26 @@
# ConnectWise PSA Ticket Extraction Details
## API Endpoint
- **Path:** `/service/tickets`
- **Method:** `GET`
- **Critical Headers:**
- `Authorization: Basic <base64_auth>`
- `clientId: <client_id>`
- `Accept: application/json`
## Filtering Conditions
To isolate open tickets on a specific board for a specific owner, use the `conditions` query parameter:
`closedFlag=false AND board/id=1 AND owner/identifier='{tech_identifier}'`
## Data Structure Quirks
The API response can be inconsistent. It may return:
1. A raw list: `[...]`
2. A dictionary containing a list: `{"tickets": [...]}`
**Implementation Note:** Always check if the response is a list; if it's a dictionary, extract the `tickets` key before iterating.
## Email Styling (Outlook Optimized)
To prevent Outlook from stripping styles:
- Use `border-collapse: collapse;` on tables.
- Use inline `style` attributes on `<td>` and `<tr>` tags.
- Avoid external CSS or `<style>` blocks.
- Use a standard system font stack: `'Segoe UI', Tahoma, Geneva, Verdana, sans-serif`.
@@ -0,0 +1,74 @@
# ConnectWise Customer Aggregation Recipe
Reference: How to aggregate open tickets by customer and enrich with primary contact details.
## Use Case
- "List customers who have had open tickets in the past N years"
- "Show me every company with active Help Desk tickets and their primary contact email"
## API Traversal
```python
import base64
import requests
from datetime import datetime, timedelta
# Auth
creds = {...} # from ~/.secrets/connectwise
auth_str = f"{company_id}{public_key}:{private_key}"
encoded_auth = base64.b64encode(auth_str.encode()).decode()
headers = {
'Authorization': f'Basic {encoded_auth}',
'clientId': client_id,
'Company-Id': company_id,
'Content-Type': 'application/json'
}
# 1. Fetch tickets
base_url = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
two_years_ago = (datetime.now() - timedelta(days=730)).strftime('%Y-%m-%dT%H:%M:%SZ')
conditions = f"board/id=1 and closedFlag=false and dateEntered>'{two_years_ago}'"
url = f"{base_url}/service/tickets?conditions={conditions}&pageSize=1000"
tickets = requests.get(url, headers=headers).json()
# 2. Aggregate by company
companies = {}
for t in tickets:
status_name = t.get('status', {}).get('name', '')
if status_name.startswith('>'):
continue
comp_id = t.get('company', {}).get('id')
if not comp_id:
continue
companies.setdefault(comp_id, {
'name': t['company']['name'],
'ticket_count': 0,
'latest_date': t['_info']['dateEntered']
})
companies[comp_id]['ticket_count'] += 1
if t['_info']['dateEntered'] > companies[comp_id]['latest_date']:
companies[comp_id]['latest_date'] = t['_info']['dateEntered']
# 3. Enrich with primary contact email
for comp_id, data in companies.items():
comp = requests.get(f"{base_url}/company/companies/{comp_id}", headers=headers).json()
contact_id = comp.get('defaultContact', {}).get('id')
data['contact_name'] = comp.get('defaultContact', {}).get('name', 'N/A')
data['email'] = comp.get('invoiceToEmailAddress', 'N/A')
if contact_id:
contact = requests.get(f"{base_url}/company/contacts/{contact_id}", headers=headers).json()
for item in contact.get('communicationItems', []):
if item.get('type', {}).get('name') == 'Email' and item.get('defaultFlag'):
data['email'] = item['value']
break
```
## Board IDs
- VistaNet Help Desk = 1
- VistaNet Management = 25
- Managed Service Alerts = 6
## Known Gaps
- Some companies (e.g., BJ LaRossa, Far West Rice Inc.) have no default contact email in CW. Display `N/A` gracefully.
- `invoiceToEmailAddress` on the company record is often billing/AP, not the operational contact.
@@ -0,0 +1,115 @@
# ConnectWise Customer Audit n8n Workflow
## Architecture
Webhook (`companyName`, `toEmail`, `fromEmail`, `subject`, `startDate`, `endDate`) → Native CW Node (`Get a company`) → Parallel Branches (Tickets + Time) → Merge → Code Node (HTML Report) → Send Email
## Workflow Details
| Property | Value |
|----------|-------|
| **Name** | ConnectWise Customer Ticket Time Audit |
| **ID** | `<workflowId>` |
| **Trigger** | Webhook (POST) |
| **URL** | `https://<n8n-host>/webhook/<webhook-path>` |
| **Active Version** | `be322bfd-5522-47e6-9198-6550940284a6` |
## Key Nodes
### 1. Webhook Trigger
- **Type:** `n8n-nodes-base.webhook` (v2.1)
- **Method:** `POST`
- **Path:** `<webhook-path>`
- **Expected JSON body:**
```json
{
"companyName": "Customer Name",
"toEmail": "recipient@example.com",
"fromEmail": "reports@vistanetinc.com",
"subject": "Customer Audit Report",
"startDate": "2025-01-01T00:00:00Z",
"endDate": "2025-12-31T23:59:59Z"
}
```
- All fields except `companyName` are optional.
- **Inside the Code node, access the payload as:** `$('Webhook').first().json.body || $('Webhook').first().json`
### 2. Get a company
- **Type:** `@mspcopilot/n8n-nodes-connectwise.connectWisePsa`
- **Resource:** `company`
- **Operation:** `get`
- **Company:** `__rl` mode with `value: "=?conditions=name like '%{{ $json.body?.companyName ?? $json.companyName }}%'"`
- ⚠️ This uses a search condition inside a `get` call, which may fail if the API returns `500 Length cannot be less than zero`.
### 3. Get many tickets
- **Type:** `connectWisePsa`
- **Resource:** `ticket`
- **Operation:** `getAll`
- **Return All:** `true`
- **Conditions:** `"=company/id={{ $json.id }}"`
### 4. Get many time entries
- **Type:** `connectWisePsa`
- **Resource:** `timeEntry`
- **Operation:** `getAll`
- **Return All:** `true`
- **Conditions:** `"=company/id={{ $json.id }}"`
### 5. Combine Data
- **Type:** `n8n-nodes-base.merge` (v3.2)
- **Mode:** `combineByPosition`
### 6. Generate HTML Report
- **Type:** `n8n-nodes-base.code` (v2)
- **Mode:** Must use `runOnceForAllItems`.
- **Logic:** Accesses upstream nodes via `$('Node Name')`, aggregates `actualHours`, filters open/closed by `status.name`, and passes through email params from the webhook payload.
- **Hour field:** `actualHours` (NOT `hours`)
- **Per-ticket hours:** Must build a `ticketHoursMap` by summing `actualHours` from time entries grouped by `ticket.id`. Do NOT read `ticket.actualHours` directly.
- **Timeframe filtering:** When `startDate`/`endDate` are provided, filter time entries by `timeStart`, then filter the ticket list to only show tickets with hours in the range. Sort by hours descending.
- **Return shape:**
```javascript
return [{ json: {
htmlReport,
totalBilledHours,
totalTickets,
openTickets,
closedTickets,
companyName,
fromEmail, // from webhook payload (flattened)
toEmail, // from webhook payload (flattened)
subject // from webhook payload (flattened)
} }];
```
### 7. Send an Email
- **Type:** `n8n-nodes-base.emailSend` (v2.1)
- **fromEmail:** `={{ $json.fromEmail ?? "reports@vistanetinc.com" }}`
- **toEmail:** `={{ $json.toEmail ?? "<recipient>" }}`
- **subject:** `={{ $json.subject ?? "Customer Audit Report" }}`
- **html:** `={{ $json.htmlReport }}`
## Critical Pitfalls
1. **Syntax Error in Set Node:** Putting multi-line JS into a `Set` node `manual` mode assignment causes `invalid syntax [item 0]`. Always use `Code` node for report generation.
2. **Time Entries in n8n:** The native `connectWisePsa` node for `timeEntry` returns `actualHours`, not `hours`. The Code node must aggregate `actualHours`.
3. **Per-Ticket Hours Must Be Rolled Up:** Do NOT use `ticket.hours` or `ticket.actualHours` in the report table. Build a lookup map by iterating time entries and summing `actualHours` per `ticket.id`.
4. **Company Search Bug:** The ConnectWise API `/company/companies` endpoint may crash with `500 Length cannot be less than zero`. There is no client-side workaround — it must be resolved by ConnectWise support.
5. **Node Name References in Code:** Ensure `$('Node Name')` references match the exact display names of the nodes on the canvas.
6. **Activation Required:** After updating a workflow via MCP, you must call `publish_workflow` to activate the new version. Until published, the old version continues executing. Verify by checking `versionId` vs `activeVersionId` in `get_workflow_details`.
7. **GET vs POST Webhook:** When switching a webhook from GET to POST, update both the HTTP method in the Webhook node AND the `$json` accessor in downstream nodes (`$json.query?.companyName` → `$json.body?.companyName`). The payload shape changes from query params to JSON body.
8. **POST Webhook Body Nesting:** Inside a Code node, POST webhook data is under `$('Webhook').first().json.body`, not directly on `$json`. Use `$('Webhook').first().json.body || $('Webhook').first().json` to handle both cases.
9. **Payload Fallthrough:** If a Code node passes values through to a downstream node (e.g., `fromEmail`, `toEmail`, `subject`), the downstream expressions must reference `$json.fromEmail`, not `$json.body.fromEmail`, because the Code node flattens the payload into its output JSON.
10. **Timeframe Filtering — Filter the Ticket List, Not Just Hours:** When a timeframe is active, filter the displayed ticket list to only tickets with time entries in the range. Do not show all tickets with 0.00 hours. Sort the filtered list by hours descending.
## SDK Code Reference
```javascript
import { workflow, node, trigger, merge } from '@n8n/workflow-sdk';
const webhookTrigger = trigger({ type: 'n8n-nodes-base.webhook', ... });
const searchCompany = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
const fetchTickets = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
const fetchTime = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
const combineData = merge({ ... });
const generateReport = node({ type: 'n8n-nodes-base.code', ... });
export default workflow('cw-audit', 'ConnectWise Customer Audit')
.add(webhookTrigger).to(searchCompany)
.to(fetchTickets.to(combineData.input(0)))
.add(searchCompany).to(fetchTime.to(combineData.input(1)))
.add(combineData).to(generateReport);
```
@@ -0,0 +1,90 @@
# ConnectWise Raw API Authentication & Time Tracking
## Correct Auth Construction
The credential file at `~/.secrets/connectwise` is a multi-line `.env` file, NOT a raw string.
```
CW_COMPANY_ID=VNI
CW_PUBLIC_KEY=+<PUBLIC_KEY>
CW_PRIVATE_KEY=<PRIVATE_KEY>
```
**WRONG:** Base64-encoding the entire file contents as the Authorization header.
**RIGHT:** Parse the key=value pairs first.
```python
import base64
import os
with open(os.path.expanduser("~/.secrets/connectwise"), "r") as f:
keys = {}
for line in f.read().strip().splitlines():
if '=' in line and not line.startswith('#'):
k, v = line.split('=', 1)
keys[k.strip()] = v.strip()
company_id = keys['CW_COMPANY_ID']
public_key = keys['CW_PUBLIC_KEY']
private_key = keys['CW_PRIVATE_KEY']
# CRITICAL: public_key already starts with '+', so do NOT add another
auth_string = f"{company_id}+{public_key}:{private_key}"
if '++' in auth_string:
auth_string = auth_string.replace('++', '+')
encoded_auth = base64.b64encode(auth_string.encode()).decode()
headers = {
"Authorization": f"Basic {encoded_auth}",
"clientId": "<CLIENT_ID>",
"Content-Type": "application/json"
}
```
## Time Entries (Not `/service/time`)
| Wrong | Right |
|-------|-------|
| `/service/time` | `/time/entries` |
| `hours` field | `actualHours` field |
| `dateEntered>'...'` (quotes) | `dateEntered>[...]` (square brackets) |
### Working Example
```python
from datetime import datetime, timedelta
twelve_months_ago = (datetime.utcnow() - timedelta(days=365)).strftime('%Y-%m-%dT%H:%M:%SZ')
url = (
"https://cw.vistanetinc.com/v4_6_release/apis/3.0"
f"/time/entries?conditions=company/id={company_id} and dateEntered>[{twelve_months_ago}]"
"&pageSize=1000"
)
resp = requests.get(url, headers=headers, timeout=30)
entries = resp.json()
total_hours = sum(e.get('actualHours', 0) or 0 for e in entries)
```
## Base URL
- **VistaNet internal:** `https://cw.vistanetinc.com/v4_6_release/apis/3.0`
- **Public endpoints** (`api-na.connectwise.com`) are **not reachable** from this environment.
## Known Company IDs
| Company | ID |
|---------|----|
| `<Client A>` | `<companyId>` |
| `<Client B>` | `<companyId>` |
## Summary of Errors This Fixes
| Symptom | Root Cause | Fix |
|---------|-----------|-----|
| `500 Length cannot be less than zero` | Auth header was garbage (base64 of entire .env file) | Parse key=value pairs |
| `403 Forbidden` on `/service/time` | Wrong endpoint | Use `/time/entries` |
| `None` or `0` for hours | Wrong field name | Use `actualHours` |
| `400 ApiFindCondition` for datetime | Quoted string instead of bracket syntax | Use `dateEntered>[...]` |
@@ -0,0 +1,39 @@
# ConnectWise Schedule Entries Bug — Session Notes
## Bug Summary
The `/schedule/entries` endpoint is **non-functional** for retrieving current/future calendar data. It returns a static mirror of historical entries from 2011–2012 regardless of date filters.
## Reproduction Steps
1. Query: `GET /schedule/entries?conditions=member/id=<memberId>`
2. Result: Returns ~200 entries, all dated 2011-12-12 through 2012-03-27.
3. Add date filter: `conditions=member/id=<memberId> and dateStart>[2026-08-09T00:00:00Z]`
4. Result: **Empty response** (no entries at all).
5. Query by ticket ID: `conditions=objectId=121247`
6. Result: **Empty response** (no schedule entries returned for known active ticket).
## Attempted Workarounds (All Failed)
| Approach | Result |
|----------|--------|
| General list with `member/id` | Returns 2011 data |
| Date range filter `dateStart>[...]` | Empty response |
| Date range filter `startDate>[...]` | Empty response |
| Ticket-linked query `objectId=<id>` | Empty response |
| `pageSize=1000` | No effect on date filtering |
| Different member IDs | Same behavior |
## Server Response Characteristics
- The API returns HTTP 200 with a valid JSON array.
- No error codes or status messages indicating a problem.
- The data is simply wrong (stale by ~14 years).
## Impact
- **Cannot retrieve current calendar/schedule data via REST API.**
- No known programmatic workaround exists.
- Technicians must use the ConnectWise web UI or desktop client for calendar access.
## Alternative Data Sources Tested
- **Nextcloud CalDAV**: Successfully queried personal calendar (`/remote.php/dav/calendars/scott/personal/`). Returns actual current events. Requires separate credentials.
- **ConnectWise Activities (`/sales/activities`)**: Tested but returned empty results for date ranges.
## Recommendation
Do not attempt to build automation around `/schedule/entries` for current calendar data. Treat this endpoint as **read-only historical reference** until ConnectWise fixes the bug.