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:
+131
-13
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user