feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests
New features: - ActivityController and manager for CW sales activities (CRUD) - ForecastProductController for opportunity forecast/product lines - CW member cache with dual-layer (in-memory + Redis) resolution - Catalog category/subcategory/ecosystem taxonomy module - Quote statuses type definitions with CW mapping - User-defined fields (UDF) module with cache and event refresh - Company sites CW module with serialization - Procurement manager filters (category, ecosystem, manufacturer, price, stock) - Opportunity notes CRUD and product line management via CW API - Opportunity type definitions endpoint Updates: - OpportunityController: CW refresh, company hydration, activities, custom fields - UserController: cwIdentifier field for CW member linking - CatalogItemController: category/subcategory fields from CW - PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions - API routes: procurement categories/filters, sales notes/products, opportunity types - Global events: UDF and member refresh intervals on startup Tests (414 passing): - ActivityController, ForecastProductController, OpportunityController unit tests - UserController cwIdentifier tests - catalogCategories, companySites, memberCache, procurement module tests - activityTypes, opportunityTypes, quoteStatuses type tests - permissionNodes subCategories and getAllPermissionNodes tests - Updated test setup with redis mock, API method mocks, and builder helpers
This commit is contained in:
+722
-26
@@ -10,6 +10,26 @@ http://localhost:3000/v1
|
||||
|
||||
---
|
||||
|
||||
## Object Type Field-Level Gating
|
||||
|
||||
All fetch and fetchAll endpoints gate response object keys via `processObjectValuePerms`. Each key on the returned object is checked against `<scope>.<field>` — only fields the user has permission for are included in the response. Grant `<scope>.*` to see all fields.
|
||||
|
||||
| Object Type | Scope | Affected Routes |
|
||||
| --------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Company | `obj.company` | `GET /company/companies`, `GET /company/companies/:identifier` |
|
||||
| Credential | `obj.credential` | `GET /credential/credentials/:id`, `GET /credential/credentials/company/:companyId`, `GET /credential/credentials/:id/sub-credentials`, `GET /credential-type/:id/credentials` |
|
||||
| Credential Type | `obj.credentialType` | `GET /credential-type/:identifier`, `GET /credential-type/` |
|
||||
| User | `obj.user` | `GET /user/@me`, `GET /user/users/:identifier`, `GET /user/users`, `GET /role/:identifier/users` |
|
||||
| Role | `obj.role` | `GET /role/:identifier`, `GET /role/`, `GET /user/users/:identifier/roles` |
|
||||
| Catalog Item | `obj.catalogItem` | `GET /procurement/items`, `GET /procurement/items/:identifier`, `GET /procurement/items/:identifier/linked` |
|
||||
| Opportunity | `obj.opportunity` | `GET /sales/opportunities`, `GET /sales/opportunities/:identifier` |
|
||||
| UniFi Site | `obj.unifiSite` | `GET /unifi/sites`, `GET /unifi/site/:id`, `GET /company/companies/:identifier/unifi/sites` |
|
||||
| WiFi Network | `unifi.site.wifi.read` | `GET /unifi/site/:id/wifi` |
|
||||
|
||||
See [PERMISSIONS.md](PERMISSIONS.md) for the full list of field-level permission nodes within each scope.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Routes
|
||||
|
||||
### Get Authentication URI
|
||||
@@ -94,6 +114,8 @@ Fetch the currently authenticated user's information.
|
||||
|
||||
**Required Scopes:** `user.read`
|
||||
|
||||
**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
@@ -211,6 +233,8 @@ Fetch a list of all users.
|
||||
|
||||
**Required Permissions:** `user.read.other`, `user.list.other`
|
||||
|
||||
**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
@@ -243,6 +267,8 @@ Fetch a specific user by their ID.
|
||||
|
||||
**Required Permissions:** `user.read.other`
|
||||
|
||||
**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` - The user's ID
|
||||
@@ -392,6 +418,8 @@ Fetch all roles assigned to a specific user.
|
||||
|
||||
**Required Permissions:** `user.read.other`, `role.read`
|
||||
|
||||
**Field-Level Gating:** `obj.role` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` - The user's ID
|
||||
@@ -478,6 +506,8 @@ Fetch a paginated list of all companies with optional search functionality.
|
||||
|
||||
**Required Permissions:** `company.fetch.many`
|
||||
|
||||
**Field-Level Gating:** `obj.company` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `page` (optional) - Page number (default: 1)
|
||||
@@ -526,6 +556,8 @@ Fetch a single company by its ID. Automatically fetches fresh data from ConnectW
|
||||
- `company.fetch.address` (required when `includeAddress=true`)
|
||||
- `company.fetch.contacts` (required when `includeAllContacts=true`)
|
||||
|
||||
**Field-Level Gating:** `obj.company` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `identifier` - Company ID (internal database ID)
|
||||
@@ -685,6 +717,8 @@ Fetch all UniFi sites linked to a specific company.
|
||||
|
||||
**Required Permissions:** `unifi.access`, `company.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.unifiSite` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `identifier` - Company ID
|
||||
@@ -752,6 +786,8 @@ Fetch a single credential by its ID.
|
||||
|
||||
**Required Permissions:** `credential.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `id` - Credential ID
|
||||
@@ -815,6 +851,8 @@ Fetch all credentials associated with a specific company.
|
||||
|
||||
**Required Permissions:** `credential.fetch.many`
|
||||
|
||||
**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `companyId` - Company ID
|
||||
@@ -1236,6 +1274,8 @@ Fetch all sub-credentials that belong to a specific parent credential.
|
||||
|
||||
**Required Permissions:** `credential.fetch`, `credential.sub_credentials.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `id` - Parent Credential ID
|
||||
@@ -1381,6 +1421,8 @@ Fetch a single credential type by its ID or name.
|
||||
|
||||
**Required Permissions:** `credential_type.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.credentialType` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `identifier` - Credential Type ID or name
|
||||
@@ -1432,6 +1474,8 @@ Fetch all credential types in the system.
|
||||
|
||||
**Required Permissions:** `credential_type.fetch.many`
|
||||
|
||||
**Field-Level Gating:** `obj.credentialType` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
@@ -1672,6 +1716,8 @@ Fetch all credentials that use a specific credential type.
|
||||
|
||||
**Required Permissions:** `credential_type.fetch`, `credential.fetch.many`
|
||||
|
||||
**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `id` - Credential Type ID
|
||||
@@ -1766,6 +1812,8 @@ Fetch a single role by its ID or moniker.
|
||||
|
||||
**Required Permissions:** `role.read`
|
||||
|
||||
**Field-Level Gating:** `obj.role` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `identifier` - Role ID or moniker
|
||||
@@ -1805,6 +1853,8 @@ Fetch all roles in the system.
|
||||
|
||||
**Required Permissions:** `role.read`, `role.list`
|
||||
|
||||
**Field-Level Gating:** `obj.role` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
@@ -2019,6 +2069,8 @@ Fetch all users that have been assigned a specific role.
|
||||
|
||||
**Required Permissions:** `role.read`, `user.read`
|
||||
|
||||
**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `identifier` - Role ID or moniker
|
||||
@@ -2216,12 +2268,22 @@ Fetch a paginated list of catalog items. Supports search.
|
||||
|
||||
**Required Permissions:** `procurement.catalog.fetch.many`
|
||||
|
||||
**Field-Level Gating:** `obj.catalogItem` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `page` (optional, default `1`) — Page number
|
||||
- `rpp` (optional, default `30`) — Records per page
|
||||
- `search` (optional) — Search by name, description, part number, vendor SKU, or manufacturer
|
||||
- `includeInactive` (optional, default `false`) — Include inactive catalog items in results
|
||||
- `category` (optional) — Filter by CW category name (e.g. `Technology`, `Field`, `General`)
|
||||
- `subcategory` (optional) — Filter by CW subcategory name (e.g. `Network-Switch`, `AlarmBurg-Panels`)
|
||||
- `group` (optional) — Filter by umbrella group name (e.g. `Network`, `AlarmBurg`, `Cables`). When used with `category`, returns items whose subcategory belongs to that group within the category.
|
||||
- `manufacturer` (optional) — Filter by manufacturer name (case-insensitive contains match)
|
||||
- `ecosystem` (optional) — Filter by ecosystem name (e.g. `Networking`, `Video Surveillance`, `Burg/Alarm`). Applies manufacturer + category + subcategory-prefix matching rules.
|
||||
- `inStock` (optional, default `false`) — When `true`, only return items with `onHand > 0`
|
||||
- `minPrice` (optional) — Minimum price filter
|
||||
- `maxPrice` (optional) — Maximum price filter
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -2237,6 +2299,10 @@ Fetch a paginated list of catalog items. Supports search.
|
||||
"description": "Dell OptiPlex 7020 SFF Desktop",
|
||||
"customerDescription": "Business Desktop Computer",
|
||||
"internalNotes": null,
|
||||
"category": "Technology",
|
||||
"categoryCwId": 18,
|
||||
"subcategory": "Computer-Desktop",
|
||||
"subcategoryCwId": 106,
|
||||
"manufacturer": "Dell",
|
||||
"manufactureCwId": 45,
|
||||
"partNumber": "OPT7020-SFF",
|
||||
@@ -2279,6 +2345,8 @@ Fetch a single catalog item by its internal ID or ConnectWise catalog ID.
|
||||
|
||||
**Required Permissions:** `procurement.catalog.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.catalogItem` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise catalog ID (numeric)
|
||||
@@ -2404,6 +2472,8 @@ Fetch all catalog items linked to a specific item.
|
||||
|
||||
**Required Permissions:** `procurement.catalog.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.catalogItem` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric)
|
||||
@@ -2528,20 +2598,167 @@ Remove the link between a source catalog item and a target catalog item.
|
||||
|
||||
---
|
||||
|
||||
### Get Categories & Ecosystems
|
||||
|
||||
**GET** `/procurement/categories`
|
||||
|
||||
Fetch the full category tree and ecosystem tree. The category tree defines the three-level hierarchy (Category → Group/Subcategory → Subcategory) used for organizing catalog items. The ecosystem tree defines cross-cutting product groupings by manufacturer + category + subcategory-prefix rules.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `procurement.catalog.fetch.many`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Category and ecosystem data fetched successfully!",
|
||||
"data": {
|
||||
"categories": [
|
||||
{
|
||||
"name": "Technology",
|
||||
"cwId": 18,
|
||||
"entries": [
|
||||
{
|
||||
"type": "subcategory",
|
||||
"name": "GeneralEquip",
|
||||
"cwId": 57
|
||||
},
|
||||
{
|
||||
"type": "group",
|
||||
"name": "Network",
|
||||
"subcategories": [
|
||||
{ "name": "Network-Other", "cwId": 174 },
|
||||
{ "name": "Network-Router", "cwId": 119 },
|
||||
{ "name": "Network-Switch", "cwId": 112 },
|
||||
{ "name": "Network-Wireless", "cwId": 111 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ecosystems": [
|
||||
{
|
||||
"name": "Networking",
|
||||
"manufacturers": [
|
||||
{
|
||||
"name": "Ubiquiti",
|
||||
"cwId": 248,
|
||||
"category": "Technology",
|
||||
"subcategoryPrefix": "Network-"
|
||||
},
|
||||
{
|
||||
"name": "TP-Link",
|
||||
"cwId": 259,
|
||||
"category": "Technology",
|
||||
"subcategoryPrefix": "Network-"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Filter Values
|
||||
|
||||
**GET** `/procurement/filters`
|
||||
|
||||
Fetch the distinct values available for filter dropdowns (categories, subcategories, manufacturers) in the current dataset. Optionally scope the results with category/subcategory filters to cascade dependent dropdowns.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `procurement.catalog.fetch.many`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `category` (optional) — Scope subcategories and manufacturers to items in this category
|
||||
- `subcategory` (optional) — Scope manufacturers to items in this subcategory
|
||||
- `includeInactive` (optional, default `false`) — Include inactive catalog items
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Available filter values fetched successfully!",
|
||||
"data": {
|
||||
"categories": ["Field", "General", "Technology"],
|
||||
"subcategories": [
|
||||
"Network-Other",
|
||||
"Network-Router",
|
||||
"Network-Switch",
|
||||
"Network-Wireless"
|
||||
],
|
||||
"manufacturers": ["TP-Link", "Ubiquiti"]
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sales Routes
|
||||
|
||||
Sales routes serve opportunity data stored locally and synced from ConnectWise. List, search, and count operations read from the local database. Sub-resource routes (forecasts, notes, contacts) fetch live data from ConnectWise using the opportunity's CW ID.
|
||||
Sales routes serve opportunity data stored locally and synced from ConnectWise. All opportunity responses include hydrated company data (address, contacts) fetched from ConnectWise when a linked company exists, as well as an `activities` array containing all ConnectWise activities linked to the opportunity (fetched live from CW at request time). Single-opportunity fetches additionally include full site details (address, phone, flags). Sub-resource routes (products, notes, contacts) fetch live data from ConnectWise using the opportunity's CW ID.
|
||||
|
||||
### Get Opportunity Types
|
||||
|
||||
**GET** `/sales/opportunity-types`
|
||||
|
||||
Fetch the list of all opportunity quote statuses (types). Returns a static list of canonical quote statuses with their ConnectWise IDs and legacy Optima equivalency mappings.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.fetch.many`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Opportunity Types Fetched Successfully!",
|
||||
"data": [
|
||||
{
|
||||
"id": 51,
|
||||
"name": "00. FutureLead",
|
||||
"wonFlag": false,
|
||||
"lostFlag": false,
|
||||
"closedFlag": false,
|
||||
"inactiveFlag": false,
|
||||
"defaultFlag": false,
|
||||
"enteredBy": "crobinso",
|
||||
"dateEntered": "2023-07-11T23:13:19Z",
|
||||
"_info": {
|
||||
"lastUpdated": "2024-04-28T15:03:57Z",
|
||||
"updatedBy": "crobinso"
|
||||
},
|
||||
"connectWiseId": "070f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
"optimaEquivalency": [35, 36]
|
||||
}
|
||||
],
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get All Opportunities
|
||||
|
||||
**GET** `/sales/opportunities`
|
||||
|
||||
Fetch a paginated list of opportunities. Supports search.
|
||||
Fetch a paginated list of opportunities. Supports search. Each opportunity includes hydrated company data (with address and contacts from ConnectWise) when a linked company exists.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.fetch.many`
|
||||
|
||||
**Field-Level Gating:** `obj.opportunity` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `page` (optional, default `1`) — Page number
|
||||
@@ -2574,7 +2791,33 @@ Fetch a paginated list of opportunities. Supports search.
|
||||
"name": "John Doe"
|
||||
},
|
||||
"secondarySalesRep": null,
|
||||
"company": { "id": 100, "name": "Acme Corp" },
|
||||
"company": {
|
||||
"id": "clx...",
|
||||
"name": "Acme Corp",
|
||||
"cw_Identifier": "AcmeCorp",
|
||||
"cw_CompanyId": 100,
|
||||
"cw_Data": {
|
||||
"address": {
|
||||
"line1": "123 Main St",
|
||||
"line2": null,
|
||||
"city": "Murray",
|
||||
"state": "Kentucky",
|
||||
"zip": "42071",
|
||||
"country": "United States"
|
||||
},
|
||||
"allContacts": [
|
||||
{
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"cwId": 200,
|
||||
"inactive": false,
|
||||
"title": "IT Manager",
|
||||
"phone": "555-0100",
|
||||
"email": "jane.smith@acme.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contact": { "id": 200, "name": "Jane Smith" },
|
||||
"site": { "id": 50, "name": "Main Office" },
|
||||
"customerPO": null,
|
||||
@@ -2590,7 +2833,43 @@ Fetch a paginated list of opportunities. Supports search.
|
||||
"companyId": "clx...",
|
||||
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
|
||||
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-26T10:00:00.000Z"
|
||||
"updatedAt": "2026-02-26T10:00:00.000Z",
|
||||
"customFields": [],
|
||||
"activities": [
|
||||
{
|
||||
"cwActivityId": 789,
|
||||
"name": "Follow-up Call",
|
||||
"notes": "Discuss proposal details",
|
||||
"type": { "id": 1, "name": "Call" },
|
||||
"status": { "id": 1, "name": "Open" },
|
||||
"company": {
|
||||
"id": 100,
|
||||
"identifier": "AcmeCorp",
|
||||
"name": "Acme Corp"
|
||||
},
|
||||
"contact": { "id": 200, "name": "Jane Smith" },
|
||||
"phoneNumber": "555-0100",
|
||||
"email": "jane.smith@acme.com",
|
||||
"opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
|
||||
"ticket": null,
|
||||
"agreement": null,
|
||||
"campaign": null,
|
||||
"assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
|
||||
"scheduleStatus": null,
|
||||
"reminder": null,
|
||||
"where": null,
|
||||
"dateStart": "2026-03-01T10:00:00.000Z",
|
||||
"dateEnd": "2026-03-01T10:30:00.000Z",
|
||||
"notifyFlag": false,
|
||||
"currency": null,
|
||||
"mobileGuid": null,
|
||||
"customFields": [],
|
||||
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
|
||||
"cwDateEntered": "2026-02-20T09:00:00.000Z",
|
||||
"cwEnteredBy": "JDoe",
|
||||
"cwUpdatedBy": "JDoe"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
@@ -2642,12 +2921,14 @@ Get the total number of opportunities.
|
||||
|
||||
**GET** `/sales/opportunities/:identifier`
|
||||
|
||||
Fetch a single opportunity by its internal ID or ConnectWise opportunity ID.
|
||||
Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts from ConnectWise) and full site details (with address) when available.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.opportunity` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
@@ -2676,9 +2957,52 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID.
|
||||
"name": "John Doe"
|
||||
},
|
||||
"secondarySalesRep": null,
|
||||
"company": { "id": 100, "name": "Acme Corp" },
|
||||
"company": {
|
||||
"id": "clx...",
|
||||
"name": "Acme Corp",
|
||||
"cw_Identifier": "AcmeCorp",
|
||||
"cw_CompanyId": 100,
|
||||
"cw_Data": {
|
||||
"address": {
|
||||
"line1": "123 Main St",
|
||||
"line2": null,
|
||||
"city": "Murray",
|
||||
"state": "Kentucky",
|
||||
"zip": "42071",
|
||||
"country": "United States"
|
||||
},
|
||||
"allContacts": [
|
||||
{
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"cwId": 200,
|
||||
"inactive": false,
|
||||
"title": "IT Manager",
|
||||
"phone": "555-0100",
|
||||
"email": "jane.smith@acme.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contact": { "id": 200, "name": "Jane Smith" },
|
||||
"site": { "id": 50, "name": "Main Office" },
|
||||
"site": {
|
||||
"id": 50,
|
||||
"name": "Main Office",
|
||||
"address": {
|
||||
"line1": "123 Main St",
|
||||
"line2": null,
|
||||
"city": "Murray",
|
||||
"state": "Kentucky",
|
||||
"zip": "42071",
|
||||
"country": "United States"
|
||||
},
|
||||
"phoneNumber": "555-0100",
|
||||
"faxNumber": null,
|
||||
"primaryAddressFlag": true,
|
||||
"defaultShippingFlag": true,
|
||||
"defaultBillingFlag": true,
|
||||
"defaultMailingFlag": true
|
||||
},
|
||||
"customerPO": null,
|
||||
"totalSalesTax": 0,
|
||||
"location": { "id": 1, "name": "Murray" },
|
||||
@@ -2692,7 +3016,39 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID.
|
||||
"companyId": "clx...",
|
||||
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
|
||||
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-26T10:00:00.000Z"
|
||||
"updatedAt": "2026-02-26T10:00:00.000Z",
|
||||
"customFields": [],
|
||||
"activities": [
|
||||
{
|
||||
"cwActivityId": 789,
|
||||
"name": "Follow-up Call",
|
||||
"notes": "Discuss proposal details",
|
||||
"type": { "id": 1, "name": "Call" },
|
||||
"status": { "id": 1, "name": "Open" },
|
||||
"company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
|
||||
"contact": { "id": 200, "name": "Jane Smith" },
|
||||
"phoneNumber": "555-0100",
|
||||
"email": "jane.smith@acme.com",
|
||||
"opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
|
||||
"ticket": null,
|
||||
"agreement": null,
|
||||
"campaign": null,
|
||||
"assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
|
||||
"scheduleStatus": null,
|
||||
"reminder": null,
|
||||
"where": null,
|
||||
"dateStart": "2026-03-01T10:00:00.000Z",
|
||||
"dateEnd": "2026-03-01T10:30:00.000Z",
|
||||
"notifyFlag": false,
|
||||
"currency": null,
|
||||
"mobileGuid": null,
|
||||
"customFields": [],
|
||||
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
|
||||
"cwDateEntered": "2026-02-20T09:00:00.000Z",
|
||||
"cwEnteredBy": "JDoe",
|
||||
"cwUpdatedBy": "JDoe"
|
||||
}
|
||||
]
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
@@ -2704,7 +3060,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID.
|
||||
|
||||
**POST** `/sales/opportunities/:identifier/refresh`
|
||||
|
||||
Refresh an opportunity's local data by fetching the latest from ConnectWise.
|
||||
Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
@@ -2738,7 +3094,33 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise.
|
||||
"name": "John Doe"
|
||||
},
|
||||
"secondarySalesRep": null,
|
||||
"company": { "id": 100, "name": "Acme Corp" },
|
||||
"company": {
|
||||
"id": "clx...",
|
||||
"name": "Acme Corp",
|
||||
"cw_Identifier": "AcmeCorp",
|
||||
"cw_CompanyId": 100,
|
||||
"cw_Data": {
|
||||
"address": {
|
||||
"line1": "123 Main St",
|
||||
"line2": null,
|
||||
"city": "Murray",
|
||||
"state": "Kentucky",
|
||||
"zip": "42071",
|
||||
"country": "United States"
|
||||
},
|
||||
"allContacts": [
|
||||
{
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"cwId": 200,
|
||||
"inactive": false,
|
||||
"title": "IT Manager",
|
||||
"phone": "555-0100",
|
||||
"email": "jane.smith@acme.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contact": { "id": 200, "name": "Jane Smith" },
|
||||
"site": { "id": 50, "name": "Main Office" },
|
||||
"customerPO": null,
|
||||
@@ -2754,7 +3136,39 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise.
|
||||
"companyId": "clx...",
|
||||
"cwLastUpdated": "2026-02-26T14:00:00.000Z",
|
||||
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-02-26T14:00:00.000Z"
|
||||
"updatedAt": "2026-02-26T14:00:00.000Z",
|
||||
"customFields": [],
|
||||
"activities": [
|
||||
{
|
||||
"cwActivityId": 789,
|
||||
"name": "Follow-up Call",
|
||||
"notes": "Discuss proposal details",
|
||||
"type": { "id": 1, "name": "Call" },
|
||||
"status": { "id": 1, "name": "Open" },
|
||||
"company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
|
||||
"contact": { "id": 200, "name": "Jane Smith" },
|
||||
"phoneNumber": "555-0100",
|
||||
"email": "jane.smith@acme.com",
|
||||
"opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
|
||||
"ticket": null,
|
||||
"agreement": null,
|
||||
"campaign": null,
|
||||
"assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
|
||||
"scheduleStatus": null,
|
||||
"reminder": null,
|
||||
"where": null,
|
||||
"dateStart": "2026-03-01T10:00:00.000Z",
|
||||
"dateEnd": "2026-03-01T10:30:00.000Z",
|
||||
"notifyFlag": false,
|
||||
"currency": null,
|
||||
"mobileGuid": null,
|
||||
"customFields": [],
|
||||
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
|
||||
"cwDateEntered": "2026-02-20T09:00:00.000Z",
|
||||
"cwEnteredBy": "JDoe",
|
||||
"cwUpdatedBy": "JDoe"
|
||||
}
|
||||
]
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
@@ -2762,11 +3176,11 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise.
|
||||
|
||||
---
|
||||
|
||||
### Get Opportunity Forecasts
|
||||
### Get Opportunity Products
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/forecasts`
|
||||
**GET** `/sales/opportunities/:identifier/products`
|
||||
|
||||
Fetch forecast/revenue items for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
|
||||
Fetch products (forecast/revenue line items) for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
@@ -2781,25 +3195,125 @@ Fetch forecast/revenue items for an opportunity. Data is fetched live from Conne
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Opportunity forecasts fetched successfully!",
|
||||
"message": "Opportunity products fetched successfully!",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"forecastType": "Revenue",
|
||||
"forecastMonth": "2026-03-01T00:00:00Z",
|
||||
"revenue": 50000.0,
|
||||
"cost": 30000.0,
|
||||
"forecastPercentage": 75,
|
||||
"status": { "id": 1, "name": "Open" },
|
||||
"includedFlag": true,
|
||||
"linkedFlag": false,
|
||||
"recurringFlag": false
|
||||
"id": 31846,
|
||||
"forecastDescription": "Service",
|
||||
"opportunity": { "id": 5150, "name": "Example Opportunity" },
|
||||
"quantity": 1,
|
||||
"status": { "id": 24, "name": "01. New" },
|
||||
"cancelled": false,
|
||||
"cancellationType": null,
|
||||
"quantityCancelled": 0,
|
||||
"cancelledReason": null,
|
||||
"cancelledDate": null,
|
||||
"catalogItem": {
|
||||
"id": 3756,
|
||||
"identifier": "Labor & Installation - Field"
|
||||
},
|
||||
"productDescription": "Labor & Installation - Field",
|
||||
"productClass": "Service",
|
||||
"forecastType": "Service",
|
||||
"revenue": 650000,
|
||||
"cost": 0,
|
||||
"margin": 650000,
|
||||
"profit": 650000,
|
||||
"percentage": 100,
|
||||
"includeFlag": true,
|
||||
"linkFlag": true,
|
||||
"recurringFlag": false,
|
||||
"taxableFlag": true,
|
||||
"recurringRevenue": 0,
|
||||
"recurringCost": 0,
|
||||
"cycles": 0,
|
||||
"sequenceNumber": 1,
|
||||
"subNumber": 0,
|
||||
"cwLastUpdated": "2026-02-28T20:57:52.000Z",
|
||||
"cwUpdatedBy": "jroberts",
|
||||
"onHand": 12,
|
||||
"inStock": true
|
||||
}
|
||||
],
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
**Cancellation Fields:**
|
||||
|
||||
Product cancellation data is sourced from the ConnectWise procurement products endpoint (not the forecast endpoint). Each product includes:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | ----------- | --------------------------------------------------------------------------------------- |
|
||||
| `cancelled` | boolean | Whether the product has been cancelled (fully or partially) |
|
||||
| `cancellationType` | string/null | `"full"` if all units cancelled, `"partial"` if some cancelled, `null` if not cancelled |
|
||||
| `quantityCancelled` | number | Number of units cancelled |
|
||||
| `cancelledReason` | string/null | Reason for cancellation (if provided) |
|
||||
| `cancelledDate` | string/null | ISO 8601 timestamp of when the item was cancelled |
|
||||
|
||||
**Inventory Fields:**
|
||||
|
||||
Internal inventory data is sourced from the local CatalogItem database. If the product's catalog item exists locally, these fields are populated; otherwise they are `null`.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------- | ------------ | ---------------------------------------------------- |
|
||||
| `onHand` | number/null | Number of units currently on hand in local inventory |
|
||||
| `inStock` | boolean/null | Whether the item is in stock (`onHand > 0`) |
|
||||
|
||||
---
|
||||
|
||||
### Resequence Opportunity Products
|
||||
|
||||
**PATCH** `/sales/opportunities/:identifier/products/sequence`
|
||||
|
||||
Update the sequence order of products (forecast items) on an opportunity. Sends a `sequenceNumber` PATCH to each forecast item in ConnectWise.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.product.update`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"orderedIds": [31846, 31847, 31848]
|
||||
}
|
||||
```
|
||||
|
||||
- `orderedIds` — Array of forecast item IDs in the desired sequence order. Position in the array determines the `sequenceNumber` (1-based).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Product sequence updated successfully!",
|
||||
"data": {
|
||||
"products": [
|
||||
{
|
||||
"id": 31850,
|
||||
"forecastDescription": "Service",
|
||||
"sequenceNumber": 1,
|
||||
"..."
|
||||
}
|
||||
],
|
||||
"idMap": {
|
||||
"31846": 31850,
|
||||
"31847": 31851,
|
||||
"31848": 31852
|
||||
}
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
- `data.products` — Full updated product objects (IDs may change after PUT to ConnectWise).
|
||||
- `data.idMap` — Maps each original forecast item ID (from the request) to the new ID returned by ConnectWise. Use this to update references in the UI.
|
||||
|
||||
---
|
||||
|
||||
### Get Opportunity Notes
|
||||
@@ -2828,7 +3342,12 @@ Fetch notes for an opportunity. Data is fetched live from ConnectWise using the
|
||||
"text": "Client expressed interest in a full network refresh.",
|
||||
"type": { "id": 2, "name": "Discussion" },
|
||||
"flagged": false,
|
||||
"enteredBy": "JDoe"
|
||||
"enteredBy": {
|
||||
"id": "clx1abc123",
|
||||
"identifier": "jdoe",
|
||||
"name": "John Doe",
|
||||
"cwMemberId": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"successful": true
|
||||
@@ -2837,6 +3356,179 @@ Fetch notes for an opportunity. Data is fetched live from ConnectWise using the
|
||||
|
||||
---
|
||||
|
||||
### Get Single Opportunity Note
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/notes/:noteId`
|
||||
|
||||
Fetch a single note by its ConnectWise note ID for an opportunity.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.fetch`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
- `noteId` — The ConnectWise note ID (numeric)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Opportunity note fetched successfully!",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"text": "Client expressed interest in a full network refresh.",
|
||||
"type": { "id": 2, "name": "Discussion" },
|
||||
"flagged": false,
|
||||
"enteredBy": {
|
||||
"id": "clx1abc123",
|
||||
"identifier": "jdoe",
|
||||
"name": "John Doe",
|
||||
"cwMemberId": 10
|
||||
}
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Opportunity Note
|
||||
|
||||
**POST** `/sales/opportunities/:identifier/notes`
|
||||
|
||||
Create a new note on an opportunity in ConnectWise.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.note.create`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Follow up with client about pricing.",
|
||||
"flagged": false
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------- | -------- | ------------------------------- |
|
||||
| `text` | string | Yes | The note text (min 1 character) |
|
||||
| `flagged` | boolean | No | Whether the note is flagged |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 201,
|
||||
"message": "Opportunity note created successfully!",
|
||||
"data": {
|
||||
"id": 42,
|
||||
"text": "Follow up with client about pricing.",
|
||||
"type": null,
|
||||
"flagged": false,
|
||||
"enteredBy": {
|
||||
"id": "clx2def456",
|
||||
"identifier": "jroberts",
|
||||
"name": "John Roberts",
|
||||
"cwMemberId": 15
|
||||
}
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Opportunity Note
|
||||
|
||||
**PATCH** `/sales/opportunities/:identifier/notes/:noteId`
|
||||
|
||||
Update an existing note on an opportunity in ConnectWise.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.note.update`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
- `noteId` — The ConnectWise note ID (numeric)
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Updated note text.",
|
||||
"flagged": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------- | -------- | ----------------------------------- |
|
||||
| `text` | string | No | Updated note text (min 1 character) |
|
||||
| `flagged` | boolean | No | Updated flagged state |
|
||||
|
||||
> At least one of `text` or `flagged` must be provided.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Opportunity note updated successfully!",
|
||||
"data": {
|
||||
"id": 42,
|
||||
"text": "Updated note text.",
|
||||
"type": null,
|
||||
"flagged": true,
|
||||
"enteredBy": {
|
||||
"id": "clx2def456",
|
||||
"identifier": "jroberts",
|
||||
"name": "John Roberts",
|
||||
"cwMemberId": 15
|
||||
}
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Delete Opportunity Note
|
||||
|
||||
**DELETE** `/sales/opportunities/:identifier/notes/:noteId`
|
||||
|
||||
Delete a note from an opportunity in ConnectWise.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.note.delete`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
- `noteId` — The ConnectWise note ID (numeric)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Opportunity note deleted successfully!",
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Opportunity Contacts
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/contacts`
|
||||
@@ -2891,6 +3583,8 @@ Fetch all UniFi site records from the database.
|
||||
|
||||
**Required Permissions:** `unifi.access`, `unifi.sites.fetch.many`
|
||||
|
||||
**Field-Level Gating:** `obj.unifiSite` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
@@ -3001,6 +3695,8 @@ Fetch a single UniFi site record from the database by its internal ID.
|
||||
|
||||
**Required Permissions:** `unifi.access`, `unifi.sites.fetch`
|
||||
|
||||
**Field-Level Gating:** `obj.unifiSite` — see [Object Type Field-Level Gating](#object-type-field-level-gating)
|
||||
|
||||
**URL Parameters:**
|
||||
|
||||
- `id` - Internal UniFi site ID (database ID)
|
||||
|
||||
Reference in New Issue
Block a user