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:
commit
9c96092034
36 files changed
+310875
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
# ISP / Bandwidth Agreement Lookup Pattern
|
||||
|
||||
## Goal
|
||||
Find a client's Internet connectivity details (speed, ISP, circuit ID, cost) using only the ConnectWise PSA API.
|
||||
|
||||
## Steps
|
||||
1. **Resolve the company** using a broad partial name search:
|
||||
`GET /company/companies?conditions=name contains "{partial}"`
|
||||
Inspect results and match by `identifier` (e.g., `ABCOInsurance`).
|
||||
2. **List ISP configurations** (if any):
|
||||
`GET /company/configurations?companyId={id}`
|
||||
Filter by `type.name == "ISP"` or inspect `name` for ISP keywords.
|
||||
3. **Fetch agreement details**:
|
||||
`GET /finance/agreements?conditions=company/id={id}`
|
||||
Filter by `type.name` (e.g., `ISP Bandwidth/Fiber`) to locate the bandwidth contract.
|
||||
4. **Key fields to surface**:
|
||||
- Agreement: `name`, `agreementStatus`, `billAmount`, `startDate`, `nextInvoiceDate`
|
||||
- Config: `name`, `ipAddress`, `modelNumber`, `vendorNotes`
|
||||
- Notes may contain circuit IDs, port details, or rack locations.
|
||||
|
||||
## Example Session (worked example; client details redacted)
|
||||
- Company ID: `<companyId>`, Identifier: `<ClientIdentifier>`
|
||||
- Config: `<site>-Comcast Fiber` (ISP type) with circuit notes
|
||||
- Agreements: `<tier> Internet Fiber` (`$<amount>/mo`) + `municipal/university easement riders` (`$<amount>/mo)`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Correlating Meeting Agenda Items with ConnectWise Tickets
|
||||
|
||||
When a user provides a meeting agenda with client-specific items (locations, projects, systems), search ConnectWise for related tickets to surface existing work and avoid duplicates.
|
||||
|
||||
## Search Pattern
|
||||
|
||||
Use the `summary contains` condition on `/service/tickets`:
|
||||
|
||||
```python
|
||||
data, status = list_tickets(
|
||||
conditions='summary contains "KEYWORD"',
|
||||
page_size=200
|
||||
)
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Keyword choice**: Search for the distinctive noun (location name, project name, system name) rather than generic terms like "setup" or "install". In the example below, the location keyword "Harborview" found the right ticket; "firewall" would have returned dozens.
|
||||
- **False positives**: A broad keyword may match old closed tickets for other clients. Always inspect `company.name` and `site.name` to confirm relevance.
|
||||
- **Status filter**: Do NOT pre-filter by `closedFlag=false` in the API query — the legacy mirror bug can omit current production tickets. Fetch all matches and filter client-side: exclude statuses starting with `>` (e.g., `>Closed`) and tickets with a `dateResolved` value.
|
||||
|
||||
## Example
|
||||
|
||||
**Agenda item**: "Harborview — Setup this month, 5 workstations, firewall install, WAN carrier needed, range <siteLAN>/24"
|
||||
|
||||
**Search**:
|
||||
```python
|
||||
data, status = list_tickets(conditions='summary contains "Harborview"', page_size=200)
|
||||
```
|
||||
|
||||
**Result**: Ticket `<ticketId>` — "Firewall for Harborview" (`<Client Name>`, `<Site>` site, Must Schedule, owner `<staff-initials>`, resources `<staff-initials>`, entered 2026-05-20).
|
||||
|
||||
This linked the agenda item directly to existing tracked work and surfaced that the ticket had been stalled in "Must Schedule" since May.
|
||||
|
||||
## Verification
|
||||
|
||||
After finding a candidate ticket, always read the full ticket (`get_ticket(id)`) to confirm:
|
||||
1. `company.identifier` matches the expected client.
|
||||
2. `site.name` matches the expected location.
|
||||
3. `closedFlag` is false and `status.name` does not start with `>`.
|
||||
4. `resources` or `owner` fields show who is assigned.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Ticket Time Entries, Notes & Status Changes — Working Payloads
|
||||
|
||||
Verified request/response shapes for documenting work on tickets via `scripts/cw_api.py`.
|
||||
|
||||
## Response shape normalization (do this FIRST in every script)
|
||||
|
||||
`cw_api.cw_request` returns `(data, http_status)` tuples, and list endpoints may wrap the payload one list deeper. Normalize before any `.get()` or loop, or scripts fail with `AttributeError: 'tuple' object has no attribute 'get'`:
|
||||
|
||||
```python
|
||||
def norm(resp):
|
||||
data = resp[0] if isinstance(resp, tuple) and len(resp) == 2 else resp
|
||||
if isinstance(data, list) and data and isinstance(data[0], list):
|
||||
data = data[0]
|
||||
return [x for x in (data if isinstance(data, list) else [data]) if isinstance(x, dict)]
|
||||
```
|
||||
|
||||
## Create a time entry on a ticket
|
||||
|
||||
`POST /time/entries`. Convert local Pacific time to UTC by adding 7 hours (1:30–3:30 PM PDT → 20:30–22:30Z):
|
||||
|
||||
```json
|
||||
{
|
||||
"company": {"id": <companyId>},
|
||||
"member": {"id": 147},
|
||||
"chargeToId": <ticketId>,
|
||||
"chargeToType": "ServiceTicket",
|
||||
"locationId": 11,
|
||||
"businessUnitId": 1,
|
||||
"workType": {"id": 3},
|
||||
"workRole": {"id": 4},
|
||||
"timeStart": "<ISO UTC>",
|
||||
"timeEnd": "<ISO UTC>",
|
||||
"actualHours": 2.0,
|
||||
"billableOption": "Billable",
|
||||
"notes": "<work note text>",
|
||||
"addToDetailDescriptionFlag": true
|
||||
}
|
||||
```
|
||||
|
||||
- `addToDetailDescriptionFlag` does NOT produce a ticket note on this instance — post the note separately if the text must appear on the ticket.
|
||||
- Verify with `GET /time/entries/{id}`: check `member.identifier`, `timeStart`/`timeEnd`, `actualHours`, and `notes` (top level; `_info.notes` only appears on the ticket timeEntries list view).
|
||||
- `billableOption`: "Billable" or "NoCharge".
|
||||
|
||||
## Add a ticket note
|
||||
|
||||
`POST /service/tickets/{id}/notes`:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "<note text>",
|
||||
"detailDescriptionFlag": true,
|
||||
"internalAnalysisFlag": false,
|
||||
"resolutionFlag": false
|
||||
}
|
||||
```
|
||||
|
||||
- Do NOT include `customerVisibleFlag` (or any field not on ServiceNote) — the API 400s the entire request: "Could not find member 'customerVisibleFlag' on object of type 'ServiceNote'".
|
||||
- Notes are attributed to the API member (TinMan), not an arbitrary author.
|
||||
- Verify with `GET /service/tickets/{id}/notes`.
|
||||
|
||||
## Change ticket status
|
||||
|
||||
1. Resolve the numeric status ID: `GET /service/boards/{boardId}/statuses`, match on `name` (VistaNet Help Desk board = 1; "In Progress" = id 1; names starting with `>` are closed-type statuses).
|
||||
2. PATCH `/service/tickets/{id}` with a JSON Patch ARRAY:
|
||||
|
||||
```json
|
||||
[{"op": "replace", "path": "status/id", "value": 1}]
|
||||
```
|
||||
|
||||
3. Verify: `GET /service/tickets/{id}?fields=id,status`.
|
||||
|
||||
## Documentation audit of a scheduled day
|
||||
|
||||
```python
|
||||
# UTC bounds: Pacific day 00:00–24:00 PDT → add 7 hours
|
||||
params = {"conditions": f"member/id={memberId} AND dateStart >= [{startUtc}] AND dateStart <= [{endUtc}]",
|
||||
"pageSize": 100}
|
||||
entries = norm(cw_api.cw_request("GET", "schedule/entries", params=params))
|
||||
# per entry: objectId = ticket id → GET /service/tickets/{id}/notes AND /service/tickets/{id}/timeEntries
|
||||
# count only notes where note["member"]["identifier"] matches the member — time entries do NOT count as notes
|
||||
# report per ticket: notes present / time-entry-only / nothing
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# ConnectWise Ticket Fetching Implementation
|
||||
Implementation details for fetching tickets from the VistaNet ConnectWise instance.
|
||||
|
||||
## API Configuration
|
||||
- **Base URL**: `https://cw.vistanetinc.com/v4_6_release/apis/3.0`
|
||||
- **Authentication**: Basic Auth using `companyid+publickey:privatekey`.
|
||||
- **Required Header**: `clientId` (Crucial: 401 Unauthorized is returned if this is missing or incorrect).
|
||||
|
||||
## Implementation Pattern
|
||||
Since server-side filtering is unreliable, the reliable pattern is:
|
||||
1. Fetch open tickets using `conditions=id >= [RecentID] AND closedFlag=false` with a large `pageSize`.
|
||||
2. Filter JSON **locally in Python** for the target company by `company.id`.
|
||||
3. (Optional) Filter for specific owner, board, or other criteria locally.
|
||||
4. Filter out statuses starting with `>` (e.g., `>Completed`) to identify truly open tickets.
|
||||
|
||||
### Why local filtering is required
|
||||
The `companyId` query parameter on `/service/tickets` is **silently ignored** by the API and returns tickets from all companies. Server-side `conditions` on identifiers also often return empty lists or incorrect results. Always fetch broad and filter client-side.
|
||||
|
||||
## Example: All open tickets for a specific company
|
||||
```python
|
||||
import base64, requests, json
|
||||
|
||||
BASE_URL = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
|
||||
CLIENT_ID = '<CLIENT_ID>'
|
||||
COMPANY_ID = 'VNI'
|
||||
PUBLIC_KEY = '<PUBLIC_KEY>'
|
||||
PRIVATE_KEY = '<PRIVATE_KEY>'
|
||||
|
||||
auth_b64 = base64.b64encode(f'{COMPANY_ID}+{PUBLIC_KEY}:{PRIVATE_KEY}'.encode()).decode()
|
||||
headers = {
|
||||
'Authorization': f'Basic {auth_b64}',
|
||||
'clientId': CLIENT_ID,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# 1. Fetch broad — companyId is ignored, so we use id range to avoid legacy mirroring
|
||||
url = f'{BASE_URL}/service/tickets?pageSize=1000&conditions=id%20%3E%3D%2080000%20AND%20closedFlag%3Dfalse'
|
||||
resp = requests.get(url, headers=headers, timeout=45)
|
||||
resp.raise_for_status()
|
||||
tickets = resp.json()
|
||||
|
||||
# 2. Filter client-side for Miller Glass (company.id == 19502)
|
||||
miller_tickets = [
|
||||
t for t in tickets
|
||||
if t.get('company', {}).get('id') == 19502
|
||||
and not t.get('status', {}).get('name', '').startswith('>')
|
||||
and t.get('dateResolved') is None
|
||||
]
|
||||
```
|
||||
|
||||
## "Open" ticket definition
|
||||
A ticket is considered open when ALL of the following are true:
|
||||
- `closedFlag` is `false`
|
||||
- Status name does NOT start with `>` (e.g., `>Closed`, `>Completed`)
|
||||
- `dateResolved` is `None` or absent
|
||||
|
||||
## Searching ticket NOTES for text across a company's tickets
|
||||
Note text is NOT searchable server-side on this instance: `POST /service/tickets/search` accepts `childconditions` in its FilterValues body but **silently ignores it** — verified that six different `childconditions` values (`notes/text contains '...'`) all returned identical result sets, with no error to warn you. Never conclude a note doesn't exist based on a childconditions query.
|
||||
|
||||
Working pattern (validated end-to-end, ~0.25 s/ticket):
|
||||
1. Resolve the company: `GET /company/companies?conditions=name contains 'Partial'` → cache `company.id`.
|
||||
2. Size the corpus: `GET /service/tickets/count?conditions=company/id={id}` (the count endpoint honors the condition, unlike the list endpoint's bare `companyId` param). Bound it with `AND dateEntered >= [YYYY-01-01T00:00:00Z]` when the ask is recency-scoped — the date condition is also honored and shrank a 4,301-ticket corpus to 1,086.
|
||||
3. Page-fetch ticket ids: `GET /service/tickets?conditions=company/id={id} AND dateEntered >= [...]&pageSize=200&page={n}&orderBy=id asc&fields=id,summary,status/name,closedFlag,dateResolved` until a short page. Persist the array to a scratch JSON file between calls — kernel state does not survive a timeout.
|
||||
4. Per ticket: `GET /service/tickets/{id}/notes?pageSize=1000` and regex the `text` client-side. pageSize=1000 on a single ticket's notes is safe; the corruption concern applies to large LIST fetches only.
|
||||
5. Triage matches by snippet before reporting: generic keywords ('office', 'field') drown in "out of the office" autoresponder noise from email-logged notes — the signal is usually in `internalFlag=true` notes written by members. Email/contact notes carry `member: null`; read `contact` or the text for attribution.
|
||||
|
||||
Budget runtime: 1,000+ tickets of note fetches takes ~4–5 minutes — chunk the scan (~300 tickets per execute_code call) and append matches to a scratch JSON so a timeout loses nothing.
|
||||
|
||||
Reference in new issue
Block a user