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:
2026-03-01 13:19:00 -06:00
parent 883b648d5e
commit d7b374f8ab
96 changed files with 7752 additions and 205 deletions
+722 -26
View File
@@ -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)