feat: add product to opportunity route, local product sequencing

- Add POST /v1/sales/opportunities/:identifier/products with field-level permission gating
- Add CWForecastItemCreate type for forecast item creation
- Store product display order locally (productSequence Int[] on Opportunity)
- Rewrite resequenceProducts to be local-only (no CW PUT, stable IDs)
- Remove reorderProducts CW util (PUT regenerated IDs & broke procurement)
- Update fetchProducts to apply local ordering with CW sequenceNumber fallback
- Add productSequence to OpportunityController.toJson()
- Update API_ROUTES.md, PERMISSIONS.md, PermissionNodes.ts
This commit is contained in:
2026-03-01 18:01:02 -06:00
parent d7b374f8ab
commit 30b408e0db
19 changed files with 1030 additions and 107 deletions
+131 -13
View File
@@ -2832,6 +2832,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
"productSequence": [31848, 31846, 31847],
"createdAt": "2026-02-01T00:00:00.000Z",
"updatedAt": "2026-02-26T10:00:00.000Z",
"customFields": [],
@@ -3015,6 +3016,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-02-26T10:00:00.000Z",
"productSequence": [31848, 31846, 31847],
"createdAt": "2026-02-01T00:00:00.000Z",
"updatedAt": "2026-02-26T10:00:00.000Z",
"customFields": [],
@@ -3180,7 +3182,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
**GET** `/sales/opportunities/:identifier/products`
Fetch products (forecast/revenue line 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. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`.
**Authentication Required:** Yes
@@ -3266,7 +3268,9 @@ Internal inventory data is sourced from the local CatalogItem database. If the p
**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.
Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.
When a `productSequence` is set, `GET .../products` returns items in that order. Any forecast items not included in the array (e.g. newly added items) are appended at the end in CW `sequenceNumber` order.
**Authentication Required:** Yes
@@ -3280,11 +3284,11 @@ Update the sequence order of products (forecast items) on an opportunity. Sends
```json
{
"orderedIds": [31846, 31847, 31848]
"orderedIds": [31848, 31846, 31847]
}
```
- `orderedIds` — Array of forecast item IDs in the desired sequence order. Position in the array determines the `sequenceNumber` (1-based).
- `orderedIds` — Array of CW forecast item IDs in the desired display order. All IDs must exist on the opportunity's forecast in ConnectWise.
**Response:**
@@ -3295,24 +3299,138 @@ Update the sequence order of products (forecast items) on an opportunity. Sends
"data": {
"products": [
{
"id": 31850,
"id": 31848,
"forecastDescription": "Hardware",
"sequenceNumber": 3,
"..."
},
{
"id": 31846,
"forecastDescription": "Service",
"sequenceNumber": 1,
"..."
},
{
"id": 31847,
"forecastDescription": "Licensing",
"sequenceNumber": 2,
"..."
}
],
"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.
- `data.products` — Full product objects in the new display order. IDs are unchanged — CW `sequenceNumber` still reflects the original CW order, but the array order matches the locally stored sequence.
---
### Add Product to Opportunity
**POST** `/sales/opportunities/:identifier/products`
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.product.add`
**Field-Level Permission Gating:** Yes — uses `processObjectValuePerms` with scope `sales.opportunity.product.field` on the **input body**. See the field-level permissions table under `sales.opportunity.product.add` in PERMISSIONS.md.
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Request Body:**
All fields are optional. Only fields the user has the corresponding `sales.opportunity.product.field.<field>` permission for will be sent to ConnectWise.
```json
{
"catalogItem": { "id": 1234 },
"forecastDescription": "Managed Services",
"productDescription": "Monthly managed services agreement",
"quantity": 1,
"status": { "id": 1 },
"productClass": "Agreement",
"forecastType": "Product",
"revenue": 500.0,
"cost": 250.0,
"includeFlag": true,
"linkFlag": false,
"recurringFlag": true,
"taxableFlag": true,
"recurringRevenue": 500.0,
"recurringCost": 250.0,
"cycles": 12,
"sequenceNumber": 1
}
```
| Field | Type | Description |
| --------------------- | ---------------- | ------------------------------------------------ |
| `catalogItem` | `{ id: number }` | ConnectWise catalog item reference |
| `forecastDescription` | string | Forecast description text |
| `productDescription` | string | Product description text |
| `quantity` | number | Quantity (must be positive) |
| `status` | `{ id: number }` | ConnectWise status reference |
| `productClass` | string | Product class (e.g. Product, Service, Agreement) |
| `forecastType` | string | Forecast type |
| `revenue` | number | Revenue amount |
| `cost` | number | Cost amount |
| `includeFlag` | boolean | Whether to include in forecast totals |
| `linkFlag` | boolean | Whether the item is linked |
| `recurringFlag` | boolean | Whether this is a recurring item |
| `taxableFlag` | boolean | Whether this item is taxable |
| `recurringRevenue` | number | Recurring revenue amount |
| `recurringCost` | number | Recurring cost amount |
| `cycles` | number | Number of recurring cycles (integer, min 0) |
| `sequenceNumber` | number | Display sequence number (integer, min 0) |
**Response:**
```json
{
"status": 201,
"message": "Product added to opportunity successfully!",
"data": {
"id": 31855,
"forecastDescription": "Managed Services",
"opportunity": { "id": 5678, "name": "Example Opportunity" },
"quantity": 1,
"status": { "id": 1, "name": "Open" },
"catalogItem": { "id": 1234, "identifier": "MSP-001" },
"productDescription": "Monthly managed services agreement",
"productClass": "Agreement",
"forecastType": "Product",
"revenue": 500.0,
"cost": 250.0,
"margin": 250.0,
"profit": 250.0,
"percentage": 0,
"includeFlag": true,
"linkFlag": false,
"recurringFlag": true,
"taxableFlag": true,
"recurringRevenue": 500.0,
"recurringCost": 250.0,
"cycles": 12,
"sequenceNumber": 1,
"subNumber": 0,
"cwLastUpdated": "2026-03-01T00:00:00.000Z",
"cwUpdatedBy": "Admin1",
"cancelled": false,
"cancellationType": null,
"quantityCancelled": 0,
"cancelledReason": null,
"cancelledDate": null,
"onHand": null,
"inStock": null
},
"successful": true
}
```
---