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
+11 -9
View File
@@ -24,9 +24,10 @@ Keep each layer focused:
## Runtime / tooling
The project runs on **Bun**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`:
The project runs on **Bun** exclusively — **always use `bun` commands, never `npm`, `npx`, or `yarn`**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Test preloads are configured in `bunfig.toml` so bare `bun test` works. Key scripts in `package.json`:
- `dev``NODE_ENV=development bun --watch src/index.ts` (start dev server with hot reload)
- `test``bun test` (runs all tests with preload from `bunfig.toml`)
- `db:gen``prisma generate`
- `db:push``prisma migrate dev --skip-generate`
- `utils:dev``docker compose -f .docker/docker-compose.yml up --build`
@@ -36,7 +37,7 @@ The project runs on **Bun**. DB tooling uses **Prisma**; the generated client li
## Data layer
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `npm run db:gen` after updating `schema.prisma`.
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `bun run db:gen` after updating `schema.prisma`.
## Shared constants (`src/constants.ts`)
@@ -174,13 +175,14 @@ The `UnifiClient` class in `src/modules/unifi-api/UnifiClient.ts` wraps all UniF
## Local dev / quick checks
- Start dev server: `npm run dev`
- Regenerate Prisma client: `npm run db:gen`
- Apply DB migrations locally: `npm run db:push`
- Docker dev utilities: `npm run utils:dev`
- Generate private keys: `npm run utils:gen_private_keys`
- Create admin role: `npm run utils:create_admin_role`
- Assign user role: `npm run utils:assign_user_role`
- Start dev server: `bun run dev`
- Run tests: `bun test`
- Regenerate Prisma client: `bun run db:gen`
- Apply DB migrations locally: `bun run db:push`
- Docker dev utilities: `bun run utils:dev`
- Generate private keys: `bun run utils:gen_private_keys`
- Create admin role: `bun run utils:create_admin_role`
- Assign user role: `bun run utils:assign_user_role`
## When editing generated or infra files
+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
}
```
---
+29 -1
View File
@@ -129,7 +129,7 @@ Admin-specific UI permissions that control visibility and data loading for admin
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
| Permission Node | Description | Used In | Dependencies |
| ---------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
@@ -137,6 +137,34 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` |
<details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
Each submitted field is gated by a `sales.opportunity.product.field.<field>` permission node. Only fields the user has permission for are forwarded to ConnectWise.
| Field Permission Node | Description |
| ----------------------------------------------------- | -------------------------------------------------------- |
| `sales.opportunity.product.field.catalogItem` | Set the catalog item reference |
| `sales.opportunity.product.field.forecastDescription` | Set the forecast description |
| `sales.opportunity.product.field.productDescription` | Set the product description |
| `sales.opportunity.product.field.quantity` | Set the quantity |
| `sales.opportunity.product.field.status` | Set the status reference |
| `sales.opportunity.product.field.productClass` | Set the product class (e.g. Product, Service, Agreement) |
| `sales.opportunity.product.field.forecastType` | Set the forecast type |
| `sales.opportunity.product.field.revenue` | Set the revenue amount |
| `sales.opportunity.product.field.cost` | Set the cost amount |
| `sales.opportunity.product.field.includeFlag` | Set the include flag |
| `sales.opportunity.product.field.linkFlag` | Set the link flag |
| `sales.opportunity.product.field.recurringFlag` | Set the recurring flag |
| `sales.opportunity.product.field.taxableFlag` | Set the taxable flag |
| `sales.opportunity.product.field.recurringRevenue` | Set the recurring revenue amount |
| `sales.opportunity.product.field.recurringCost` | Set the recurring cost amount |
| `sales.opportunity.product.field.cycles` | Set the number of recurring cycles |
| `sales.opportunity.product.field.sequenceNumber` | Set the sequence number (display order) |
</details>
### UniFi Permissions
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]
File diff suppressed because one or more lines are too long
@@ -1334,6 +1334,7 @@ export const OpportunityScalarFieldEnum = {
closedByName: 'closedByName',
closedByCwId: 'closedByCwId',
companyId: 'companyId',
productSequence: 'productSequence',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
@@ -221,6 +221,7 @@ export const OpportunityScalarFieldEnum = {
closedByName: 'closedByName',
closedByCwId: 'closedByCwId',
companyId: 'companyId',
productSequence: 'productSequence',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
+53 -1
View File
@@ -43,6 +43,7 @@ export type OpportunityAvgAggregateOutputType = {
locationCwId: number | null
departmentCwId: number | null
closedByCwId: number | null
productSequence: number | null
}
export type OpportunitySumAggregateOutputType = {
@@ -62,6 +63,7 @@ export type OpportunitySumAggregateOutputType = {
locationCwId: number | null
departmentCwId: number | null
closedByCwId: number | null
productSequence: number[]
}
export type OpportunityMinAggregateOutputType = {
@@ -206,6 +208,7 @@ export type OpportunityCountAggregateOutputType = {
closedByName: number
closedByCwId: number
companyId: number
productSequence: number
cwLastUpdated: number
createdAt: number
updatedAt: number
@@ -230,6 +233,7 @@ export type OpportunityAvgAggregateInputType = {
locationCwId?: true
departmentCwId?: true
closedByCwId?: true
productSequence?: true
}
export type OpportunitySumAggregateInputType = {
@@ -249,6 +253,7 @@ export type OpportunitySumAggregateInputType = {
locationCwId?: true
departmentCwId?: true
closedByCwId?: true
productSequence?: true
}
export type OpportunityMinAggregateInputType = {
@@ -393,6 +398,7 @@ export type OpportunityCountAggregateInputType = {
closedByName?: true
closedByCwId?: true
companyId?: true
productSequence?: true
cwLastUpdated?: true
createdAt?: true
updatedAt?: true
@@ -529,6 +535,7 @@ export type OpportunityGroupByOutputType = {
closedByName: string | null
closedByCwId: number | null
companyId: string | null
productSequence: number[]
cwLastUpdated: Date | null
createdAt: Date
updatedAt: Date
@@ -601,6 +608,7 @@ export type OpportunityWhereInput = {
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
@@ -651,6 +659,7 @@ export type OpportunityOrderByWithRelationInput = {
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
productSequence?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -704,6 +713,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
@@ -754,6 +764,7 @@ export type OpportunityOrderByWithAggregationInput = {
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
productSequence?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -811,6 +822,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
closedByName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
closedByCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
@@ -859,6 +871,7 @@ export type OpportunityCreateInput = {
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@@ -909,6 +922,7 @@ export type OpportunityUncheckedCreateInput = {
closedByName?: string | null
closedByCwId?: number | null
companyId?: string | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@@ -957,6 +971,7 @@ export type OpportunityUpdateInput = {
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1007,6 +1022,7 @@ export type OpportunityUncheckedUpdateInput = {
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1056,6 +1072,7 @@ export type OpportunityCreateManyInput = {
closedByName?: string | null
closedByCwId?: number | null
companyId?: string | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@@ -1104,6 +1121,7 @@ export type OpportunityUpdateManyMutationInput = {
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1153,6 +1171,7 @@ export type OpportunityUncheckedUpdateManyInput = {
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1168,6 +1187,14 @@ export type OpportunityOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type IntNullableListFilter<$PrismaModel = never> = {
equals?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
has?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
hasEvery?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
hasSome?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
isEmpty?: boolean
}
export type OpportunityCountOrderByAggregateInput = {
id?: Prisma.SortOrder
cwOpportunityId?: Prisma.SortOrder
@@ -1212,6 +1239,7 @@ export type OpportunityCountOrderByAggregateInput = {
closedByName?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder
companyId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -1234,6 +1262,7 @@ export type OpportunityAvgOrderByAggregateInput = {
locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder
}
export type OpportunityMaxOrderByAggregateInput = {
@@ -1351,6 +1380,7 @@ export type OpportunitySumOrderByAggregateInput = {
locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder
}
export type OpportunityCreateNestedManyWithoutCompanyInput = {
@@ -1395,6 +1425,15 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyNestedInput = {
deleteMany?: Prisma.OpportunityScalarWhereInput | Prisma.OpportunityScalarWhereInput[]
}
export type OpportunityCreateproductSequenceInput = {
set: number[]
}
export type OpportunityUpdateproductSequenceInput = {
set?: number[]
push?: number | number[]
}
export type OpportunityCreateWithoutCompanyInput = {
id?: string
cwOpportunityId: number
@@ -1438,6 +1477,7 @@ export type OpportunityCreateWithoutCompanyInput = {
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@@ -1486,6 +1526,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@@ -1564,6 +1605,7 @@ export type OpportunityScalarWhereInput = {
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
@@ -1612,6 +1654,7 @@ export type OpportunityCreateManyCompanyInput = {
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
@@ -1660,6 +1703,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1708,6 +1752,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1756,6 +1801,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -1807,6 +1853,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
closedByName?: boolean
closedByCwId?: boolean
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -1857,6 +1904,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
closedByName?: boolean
closedByCwId?: boolean
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -1907,6 +1955,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
closedByName?: boolean
closedByCwId?: boolean
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -1957,12 +2006,13 @@ export type OpportunitySelectScalar = {
closedByName?: boolean
closedByCwId?: boolean
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
}
@@ -2022,6 +2072,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
closedByName: string | null
closedByCwId: number | null
companyId: string | null
productSequence: number[]
cwLastUpdated: Date | null
createdAt: Date
updatedAt: Date
@@ -2492,6 +2543,7 @@ export interface OpportunityFieldRefs {
readonly closedByName: Prisma.FieldRef<"Opportunity", 'String'>
readonly closedByCwId: Prisma.FieldRef<"Opportunity", 'Int'>
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
+4
View File
@@ -185,6 +185,10 @@ model Opportunity {
companyId String?
company Company? @relation(fields: [companyId], references: [id])
// Local product sequence — array of CW forecast item IDs in display order.
// When present, fetchProducts() uses this order instead of CW sequenceNumber.
productSequence Int[] @default([])
cwLastUpdated DateTime?
createdAt DateTime @default(now())
+69
View File
@@ -0,0 +1,69 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
import { z } from "zod";
const productItemSchema = z
.object({
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
forecastDescription: z.string().optional(),
productDescription: z.string().optional(),
quantity: z.number().positive().optional(),
status: z.object({ id: z.number().int().positive() }).optional(),
productClass: z.string().optional(),
forecastType: z.string().optional(),
revenue: z.number().optional(),
cost: z.number().optional(),
includeFlag: z.boolean().optional(),
linkFlag: z.boolean().optional(),
recurringFlag: z.boolean().optional(),
taxableFlag: z.boolean().optional(),
recurringRevenue: z.number().optional(),
recurringCost: z.number().optional(),
cycles: z.number().int().min(0).optional(),
sequenceNumber: z.number().int().min(0).optional(),
})
.strict();
const addProductSchema = z.union([
productItemSchema,
z.array(productItemSchema).min(1, "At least one product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products */
export default createRoute(
"post",
["/opportunities/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const validated = addProductSchema.parse(body);
const inputItems = Array.isArray(validated) ? validated : [validated];
// Gate each submitted field against user permissions.
// Only fields the user has permission for are forwarded to ConnectWise.
const user = c.get("user");
const gatedItems = await Promise.all(
inputItems.map((item) =>
processObjectValuePerms(item, "sales.opportunity.product.field", user),
),
);
const item = await opportunities.fetchItem(identifier);
const created = await item.addProducts(gatedItems);
const isBatch = Array.isArray(body);
const response = apiResponse.created(
isBatch
? `${created.length} product(s) added to opportunity successfully!`
: "Product added to opportunity successfully!",
isBatch ? created.map((p) => p.toJson()) : created[0]!.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.add"] }),
);
-7
View File
@@ -24,17 +24,10 @@ export default createRoute(
const item = await opportunities.fetchItem(identifier);
const updated = await item.resequenceProducts(orderedIds);
// Map original IDs to the new IDs returned by ConnectWise
const idMap: Record<number, number> = {};
for (let i = 0; i < orderedIds.length; i++) {
idMap[orderedIds[i]!] = updated[i]!.cwForecastId;
}
const response = apiResponse.successful(
"Product sequence updated successfully!",
{
products: updated.map((p) => p.toJson()),
idMap,
},
);
+2
View File
@@ -4,6 +4,7 @@ import { default as count } from "./count";
import { default as fetch } from "./[id]/fetch";
import { default as refresh } from "./[id]/refresh";
import { default as products } from "./[id]/products";
import { default as addProduct } from "./[id]/addProduct";
import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as notes } from "./[id]/notes";
import { default as fetchNote } from "./[id]/fetchNote";
@@ -13,6 +14,7 @@ import { default as deleteNote } from "./[id]/deleteNote";
import { default as contacts } from "./[id]/contacts";
export {
addProduct,
count,
fetch,
fetchAll,
+85 -44
View File
@@ -11,6 +11,7 @@ import {
} from "../modules/cw-utils/sites/companySites";
import {
CWCustomField,
CWForecastItemCreate,
CWOpportunity,
CWOpportunityNote,
} from "../modules/cw-utils/opportunities/opportunity.types";
@@ -78,6 +79,10 @@ export class OpportunityController {
public companyId: string | null;
public cwLastUpdated: Date | null;
// Local product display order — array of CW forecast item IDs.
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
public productSequence: number[];
public readonly createdAt: Date;
public updatedAt: Date;
@@ -145,6 +150,7 @@ export class OpportunityController {
this.companyId = data.companyId;
this.cwLastUpdated = data.cwLastUpdated;
this.productSequence = data.productSequence;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
@@ -400,9 +406,29 @@ export class OpportunityController {
}
}
const controllers = (forecast.forecastItems ?? [])
.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
.map((item) => {
// Apply local ordering if productSequence is set, otherwise fall back
// to CW sequenceNumber.
const forecastItems = forecast.forecastItems ?? [];
let ordered: typeof forecastItems;
if (this.productSequence.length > 0) {
const itemById = new Map(forecastItems.map((fi) => [fi.id, fi]));
// Items in the specified order first, then any new items not yet sequenced
const sequenced = this.productSequence
.map((id) => itemById.get(id))
.filter((fi): fi is NonNullable<typeof fi> => fi !== undefined);
const sequencedIds = new Set(this.productSequence);
const unsequenced = forecastItems
.filter((fi) => !sequencedIds.has(fi.id))
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
ordered = [...sequenced, ...unsequenced];
} else {
ordered = [...forecastItems].sort(
(a, b) => a.sequenceNumber - b.sequenceNumber,
);
}
const controllers = ordered.map((item) => {
const ctrl = new ForecastProductController(item);
const procData = cancellationMap.get(item.id);
if (procData) {
@@ -556,25 +582,22 @@ export class OpportunityController {
/**
* Resequence Products
*
* Updates the sequenceNumber on each forecast item to match the
* order provided. Fetches the current items first so the PUT
* includes all required fields. Expects an array of forecast item
* IDs in the desired order.
* Stores the desired display order of forecast item IDs locally in
* the database. No CW API calls are made — CW item IDs are stable
* and ordering is applied when `fetchProducts()` is called.
*
* @param orderedIds - Forecast item IDs in the desired sequence order
* @param orderedIds - Forecast item IDs in the desired display order
*/
public async resequenceProducts(
orderedIds: number[],
): Promise<ForecastProductController[]> {
// Fetch existing items so we can include required fields in the PUT
// Validate all IDs exist in CW
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
const itemMap = new Map(
(forecast.forecastItems ?? []).map((fi) => [fi.id, fi]),
const existingIds = new Set(
(forecast.forecastItems ?? []).map((fi) => fi.id),
);
// Validate all IDs exist before making any updates
for (const id of orderedIds) {
if (!itemMap.has(id)) {
if (!existingIds.has(id)) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
@@ -583,43 +606,60 @@ export class OpportunityController {
}
}
// Run updates in reverse order to CW
const results: ForecastProductController[] = new Array(orderedIds.length);
for (let index = orderedIds.length - 1; index >= 0; index--) {
const id = orderedIds[index]!;
const existing = itemMap.get(id)!;
const raw = JSON.parse(JSON.stringify(existing)) as Record<
string,
unknown
>;
// Strip read-only _info fields at top level and nested sub-objects
delete raw._info;
for (const key of ["opportunity", "status", "catalogItem"]) {
if (raw[key] && typeof raw[key] === "object") {
delete (raw[key] as Record<string, unknown>)._info;
}
}
const newSeq = index + 1;
const result = await this.updateProduct(id, {
...raw,
sequenceNumber: newSeq,
// Persist the sequence locally
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: orderedIds },
});
this.productSequence = orderedIds;
results[index] = result;
}
return results;
// Return items in the new order
return this.fetchProducts();
}
/**
* Add Product
* Add Products
*
* Adds a new product/line item to this opportunity.
* Adds one or more products/line items to this opportunity via the
* ConnectWise forecast endpoint. The caller passes only the fields
* the user is permitted to set (already filtered by field-level
* permission gating in the route handler).
*
* Accepts a single item or an array of items.
*/
public async addProduct(): Promise<void> {
// TODO: implement
public async addProducts(
data: CWForecastItemCreate | CWForecastItemCreate[],
): Promise<ForecastProductController[]> {
try {
const created = await opportunityCw.createProducts(
this.cwOpportunityId,
data,
);
return created.map((item) => new ForecastProductController(item));
} catch (err: any) {
console.error(
`[addProducts] Failed to create forecast item(s) on opportunity ${this.cwOpportunityId}`,
JSON.stringify(
{
data,
status: err?.response?.status,
statusText: err?.response?.statusText,
responseData: err?.response?.data,
message: err?.message,
},
null,
2,
),
);
throw new GenericError({
status: err?.response?.status ?? 500,
name: "AddProductFailed",
message:
err?.response?.data?.message ??
"Failed to add product(s) to opportunity",
cause: err?.message,
});
}
}
/**
@@ -751,6 +791,7 @@ export class OpportunityController {
: null,
companyId: this.companyId,
cwLastUpdated: this.cwLastUpdated,
productSequence: this.productSequence,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
customFields: this._customFields ?? [],
@@ -5,6 +5,7 @@ import {
CWOpportunitySummary,
CWForecast,
CWForecastItem,
CWForecastItemCreate,
CWOpportunityNote,
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
@@ -118,27 +119,137 @@ export const opportunityCw = {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/forecast`,
);
console.log(
`[CW fetchProducts] Opportunity ${opportunityId} forecast raw data:`,
JSON.stringify(response.data, null, 2),
);
return response.data;
},
/**
* Create Forecast Items
*
* Adds one or more forecast items (products) to an opportunity using
* POST. The CW forecast endpoint expects a Forecast object with a
* `forecastItems` array — we wrap just the new items inside that
* structure so existing items are never sent or touched.
*/
createProducts: async (
opportunityId: number,
data: CWForecastItemCreate | CWForecastItemCreate[],
): Promise<CWForecastItem[]> => {
const items_to_add = Array.isArray(data) ? data : [data];
const url = `/sales/opportunities/${opportunityId}/forecast`;
// 1. Fetch existing forecast to derive defaults & diff IDs later
const existing = await opportunityCw.fetchProducts(opportunityId);
const existingIds = new Set(
(existing.forecastItems ?? []).map((fi) => fi.id),
);
// Derive sensible defaults from an existing item when available
const templateItem = (existing.forecastItems ?? [])[0];
const defaultStatus = templateItem?.status
? { id: templateItem.status.id }
: { id: 1 };
const defaultForecastType = templateItem?.forecastType ?? "Product";
// 2. Build forecast items with required CW fields filled in
const forecastItems = items_to_add.map((newItem) => ({
opportunity: { id: opportunityId },
status: defaultStatus,
forecastType: defaultForecastType,
...(newItem as Record<string, unknown>),
}));
// 3. POST a Forecast wrapper containing only the new items
const response = await connectWiseApi.post(url, { forecastItems });
const updatedForecast: CWForecast = response.data;
// 4. Find newly-created item(s) by diffing IDs
const newItems = (updatedForecast.forecastItems ?? []).filter(
(fi) => !existingIds.has(fi.id),
);
// Fall back to the last N items if ID diffing finds nothing
return newItems.length > 0
? newItems
: (updatedForecast.forecastItems ?? []).slice(-items_to_add.length);
},
/**
* Update Forecast Item
*
* Updates a single forecast item (product) on an opportunity using PUT.
* PATCHes a single forecast item on the parent `/forecast` endpoint.
* CW supports JSON Patch with paths like `/forecastItems/{index}/field`.
* This preserves item IDs (unlike PUT which always regenerates them)
* and does NOT recalculate revenue/cost from linked catalog items.
*
* NOTE: Not all fields are patchable — `sequenceNumber` and `quantity`
* are read-only on forecast items. Product ordering is managed locally
* via `OpportunityController.resequenceProducts()` and stored in the
* database `productSequence` field.
*/
updateProduct: async (
opportunityId: number,
forecastItemId: number,
data: Record<string, unknown>,
): Promise<CWForecastItem> => {
const url = `/sales/opportunities/${opportunityId}/forecast/${forecastItemId}`;
const response = await connectWiseApi.put(url, data);
return response.data;
const forecast = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? [];
const idx = items.findIndex((fi) => fi.id === forecastItemId);
if (idx === -1) {
throw new Error(
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
);
}
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: `/forecastItems/${idx}/${key}`,
value,
}));
const url = `/sales/opportunities/${opportunityId}/forecast`;
const response = await connectWiseApi.patch(url, operations);
const updated: CWForecast = response.data;
return (updated.forecastItems ?? [])[idx]!;
},
/**
* Bulk-update Forecast Items
*
* PATCHes multiple forecast items in a single request via the parent
* `/forecast` endpoint. All patch operations are sent in one array.
*/
bulkUpdateProducts: async (
opportunityId: number,
updates: Map<number, Record<string, unknown>>,
): Promise<CWForecastItem[]> => {
const forecast = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? [];
const operations: { op: "replace"; path: string; value: unknown }[] = [];
const touchedIndices: number[] = [];
for (const [itemId, changes] of updates) {
const idx = items.findIndex((fi) => fi.id === itemId);
if (idx === -1) {
throw new Error(
`Forecast item ${itemId} not found on opportunity ${opportunityId}`,
);
}
touchedIndices.push(idx);
for (const [key, value] of Object.entries(changes)) {
operations.push({
op: "replace",
path: `/forecastItems/${idx}/${key}`,
value,
});
}
}
const url = `/sales/opportunities/${opportunityId}/forecast`;
const response = await connectWiseApi.patch(url, operations);
const updated: CWForecast = response.data;
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
},
/**
@@ -206,6 +206,26 @@ export interface CWOpportunityContact {
_info?: Record<string, string>;
}
export interface CWForecastItemCreate {
catalogItem?: { id: number };
forecastDescription?: string;
productDescription?: string;
quantity?: number;
status?: { id: number };
productClass?: string;
forecastType?: string;
revenue?: number;
cost?: number;
includeFlag?: boolean;
linkFlag?: boolean;
recurringFlag?: boolean;
taxableFlag?: boolean;
recurringRevenue?: number;
recurringCost?: number;
cycles?: number;
sequenceNumber?: number;
}
export interface CWOpportunitySummary {
id: number;
_info?: Record<string, string>;
+26
View File
@@ -440,6 +440,32 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.add",
description:
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.",
usedIn: ["src/api/sales/[id]/addProduct.ts"],
dependencies: ["sales.opportunity.fetch"],
fieldLevelPermissions: [
"sales.opportunity.product.field.catalogItem",
"sales.opportunity.product.field.forecastDescription",
"sales.opportunity.product.field.productDescription",
"sales.opportunity.product.field.quantity",
"sales.opportunity.product.field.status",
"sales.opportunity.product.field.productClass",
"sales.opportunity.product.field.forecastType",
"sales.opportunity.product.field.revenue",
"sales.opportunity.product.field.cost",
"sales.opportunity.product.field.includeFlag",
"sales.opportunity.product.field.linkFlag",
"sales.opportunity.product.field.recurringFlag",
"sales.opportunity.product.field.taxableFlag",
"sales.opportunity.product.field.recurringRevenue",
"sales.opportunity.product.field.recurringCost",
"sales.opportunity.product.field.cycles",
"sales.opportunity.product.field.sequenceNumber",
],
},
],
},
+442
View File
@@ -0,0 +1,442 @@
/**
* Test Script: Forecast Item Resequencing & Procurement Linkage
*
* Validates the CW forecast API behaviour discovered via probing:
* - `sequenceNumber` is read-only — display order = array position
* - PUT always regenerates all forecast item IDs
* - Revenue & cost are preserved through PUT
* - PATCH on /forecast with `/forecastItems/{idx}/field` paths works
* for some fields (e.g. forecastDescription) and preserves IDs
*
* Test flow:
* 1. Create opportunity under XYZ Test Company
* 2. Add 4 products via POST
* 3. Create procurement products (linked by forecastDetailId)
* 4. Cancel one procurement product
* 5. Reorder forecast items via PUT (reverse order)
* 6. Remap procurement forecastDetailId to new IDs
* 7. Verify: order correct, prices preserved, cancellation data intact
* 8. Clean up
*
* Usage: bun run test-forecast-resequence.ts
*/
import axios from "axios";
const cw = axios.create({
baseURL: "https://ttscw.totaltech.net/v4_6_release/apis/3.0/",
headers: {
Authorization: `Basic ${process.env.CW_BASIC_TOKEN}`,
clientId: `${process.env.CW_CLIENT_ID}`,
"Content-Type": "application/json",
},
});
const log = (label: string, ...args: unknown[]) =>
console.log(`\n[${label}]`, ...args);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function main() {
// ── 1. Find company ─────────────────────────────────────────────────────
log("SETUP", "Finding XYZ Test Company...");
const compRes = await cw.get(
`/company/companies?conditions=${encodeURIComponent("name like 'XYZ Test%'")}&fields=id,identifier,name`,
);
if (compRes.data.length === 0) {
console.error("ERROR: 'XYZ Test Company' not found.");
process.exit(1);
}
const company = compRes.data[0];
log("SETUP", `Company: ${company.name} (id=${company.id})`);
// ── 2. Create opportunity ───────────────────────────────────────────────
log("SETUP", "Creating test opportunity...");
const oppRes = await cw.post("/sales/opportunities", {
name: `[TEST] Resequence ${new Date().toISOString().slice(0, 16)}`,
company: { id: company.id },
contact: { id: 1 },
primarySalesRep: { id: 153 },
expectedCloseDate: new Date(Date.now() + 30 * 86_400_000)
.toISOString()
.replace(/\.\d{3}Z$/, "Z"),
});
const oppId = oppRes.data.id;
log("SETUP", `Created opportunity id=${oppId}`);
const forecastUrl = `/sales/opportunities/${oppId}/forecast`;
// Track IDs for cleanup
const procIdsToClean: number[] = [];
try {
// ── 3. Add 4 products ───────────────────────────────────────────────────
log("PRODUCTS", "Adding 4 products...");
const postRes = await cw.post(forecastUrl, {
forecastItems: [
{
opportunity: { id: oppId },
status: { id: 1 },
forecastDescription: "Alpha",
revenue: 100,
cost: 50,
forecastType: "Product",
},
{
opportunity: { id: oppId },
status: { id: 1 },
forecastDescription: "Bravo",
revenue: 250,
cost: 125,
forecastType: "Product",
},
{
opportunity: { id: oppId },
status: { id: 1 },
forecastDescription: "Charlie",
revenue: 30,
cost: 10,
forecastType: "Product",
},
{
opportunity: { id: oppId },
status: { id: 1 },
forecastDescription: "Delta",
revenue: 75,
cost: 40,
forecastType: "Product",
},
],
});
const items: any[] = postRes.data.forecastItems ?? [];
log("PRODUCTS", `Created ${items.length} items:`);
for (const it of items) {
console.log(
` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`,
);
}
// Snapshot prices
const priceSnap = new Map<string, { rev: number; cost: number }>(
items.map((i) => [
i.forecastDescription,
{ rev: i.revenue, cost: i.cost },
]),
);
// ── 4. Create procurement products ──────────────────────────────────────
log("PROCUREMENT", "Creating procurement products...");
const procProducts: any[] = [];
for (const item of items) {
try {
const pr = await cw.post("/procurement/products", {
catalogItem: { id: 87 },
description: item.forecastDescription,
quantity: 1,
price: item.revenue,
cost: item.cost,
billableOption: "Billable",
opportunity: { id: oppId },
forecastDetailId: item.id,
});
procProducts.push(pr.data);
procIdsToClean.push(pr.data.id);
console.log(
` ✓ Proc ${pr.data.id} → forecastDetailId=${pr.data.forecastDetailId} "${item.forecastDescription}"`,
);
} catch (e: any) {
console.log(
` ✗ Failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
);
}
}
if (procProducts.length === 0) {
log(
"PROCUREMENT",
"Could not create procurement products (permission issue?).",
);
log(
"PROCUREMENT",
"Will run reorder test without cancellation verification.",
);
}
// ── 5. Cancel "Bravo" procurement product ───────────────────────────────
const bravoProc = procProducts.find((p: any) => p.description === "Bravo");
if (bravoProc) {
log("CANCEL", `Cancelling Bravo (proc id=${bravoProc.id})...`);
try {
await cw.patch(`/procurement/products/${bravoProc.id}`, [
{ op: "replace", path: "cancelledFlag", value: true },
{ op: "replace", path: "quantityCancelled", value: 1 },
{
op: "replace",
path: "cancelledReason",
value: "Test cancellation",
},
]);
log("CANCEL", "✓ Cancelled.");
} catch (e: any) {
log(
"CANCEL",
`${e.response?.status} ${JSON.stringify(e.response?.data)}`,
);
}
}
// ── 5b. Check for auto-created forecast items ─────────────────────────
await sleep(300);
const midForecast = await cw.get(forecastUrl);
const midItems = midForecast.data.forecastItems ?? [];
log(
"OBSERVE",
`Forecast items after procurement creation: ${midItems.length} (was ${items.length})`,
);
if (midItems.length !== items.length) {
log(
"OBSERVE",
"⚠ Creating procurement products auto-created additional forecast items!",
);
for (const mi of midItems) {
const isOriginal = items.some((i: any) => i.id === mi.id);
console.log(
` id=${mi.id} desc="${mi.forecastDescription}" ${isOriginal ? "(original)" : "(AUTO-CREATED by procurement)"}`,
);
}
}
// Snapshot procurement state before reorder
const beforeProc = await cw.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
);
// Build map by description for cross-PUT comparison (IDs will change)
const beforeByDesc = new Map<string, any>();
log(
"SNAPSHOT",
`${beforeProc.data.length} procurement products before reorder:`,
);
for (const p of beforeProc.data) {
beforeByDesc.set(p.description, p);
console.log(
` Proc ${p.id}: forecastDetailId=${p.forecastDetailId} cancelled=${p.cancelledFlag} qty=${p.quantityCancelled} reason="${p.cancelledReason ?? ""}" "${p.description}"`,
);
}
// Record old procurement IDs for later comparison
const oldProcIds = new Set(beforeProc.data.map((p: any) => p.id));
// ── 6. Reorder: reverse ONLY the original 4 forecast items ──────────────
log("REORDER", "Reversing forecast item order via PUT...");
// Only reorder the original items; keep any auto-created ones in place
const originalDescs = new Set(items.map((i: any) => i.forecastDescription));
const originals = midItems.filter(
(i: any) =>
originalDescs.has(i.forecastDescription) &&
items.some((o: any) => o.id === i.id),
);
const extras = midItems.filter(
(i: any) => !originals.some((o: any) => o.id === i.id),
);
const reversedOriginals = [...originals].reverse();
const reorderedAll = [...reversedOriginals, ...extras];
const clone = JSON.parse(JSON.stringify(midForecast.data));
clone.forecastItems = JSON.parse(JSON.stringify(reorderedAll));
const putRes = await cw.put(forecastUrl, clone);
const newItems: any[] = putRes.data.forecastItems ?? [];
log("REORDER", `After PUT (${newItems.length} items):`);
for (const it of newItems) {
console.log(
` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`,
);
}
// Build old→new ID map by position (for original items only)
const idMap = new Map<number, number>();
for (let i = 0; i < reversedOriginals.length && i < newItems.length; i++) {
idMap.set(reversedOriginals[i].id, newItems[i].id);
}
log("ID MAP", "Forecast item Old → New:");
for (const [oldId, newId] of idMap) {
console.log(` ${oldId}${newId}`);
}
// ── 7. Check if procurement products survived PUT ───────────────────────
await sleep(300);
const afterProc = await cw.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
);
const newProcIds = new Set(afterProc.data.map((p: any) => p.id));
log(
"PROCUREMENT SURVIVAL",
"Checking if procurement product IDs survived PUT...",
);
const procSurvived = [...oldProcIds].every((id) => newProcIds.has(id));
if (procSurvived) {
console.log(" ✓ All original procurement product IDs survived PUT.");
} else {
console.log(" ✗ PUT REGENERATED procurement product IDs!");
console.log(` Before: [${[...oldProcIds].join(", ")}]`);
console.log(` After: [${[...newProcIds].join(", ")}]`);
}
// Try remap if old IDs still exist
let remapOk = true;
if (procSurvived) {
log("REMAP", "Updating procurement products forecastDetailId...");
for (const pp of beforeProc.data) {
const oldFdId = pp.forecastDetailId as number;
const newFdId = idMap.get(oldFdId);
if (!newFdId || newFdId === oldFdId) continue;
try {
await cw.patch(`/procurement/products/${pp.id}`, [
{ op: "replace", path: "forecastDetailId", value: newFdId },
]);
console.log(
` ✓ Proc ${pp.id}: forecastDetailId ${oldFdId}${newFdId}`,
);
} catch (e: any) {
remapOk = false;
console.log(
` ✗ Proc ${pp.id} remap failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
);
}
}
} else {
remapOk = false;
log(
"REMAP",
"⚠ SKIPPED — procurement products were regenerated by PUT; old IDs no longer exist.",
);
}
// ── 8. Verify ───────────────────────────────────────────────────────────
await sleep(300);
// 8a. Verify order (first 4 items)
log("VERIFY ORDER", "Expected reverse: Delta, Charlie, Bravo, Alpha");
const expectedOrder = ["Delta", "Charlie", "Bravo", "Alpha"];
let orderOk = true;
for (let i = 0; i < expectedOrder.length; i++) {
const actual = newItems[i]?.forecastDescription;
const ok = actual === expectedOrder[i];
if (!ok) orderOk = false;
console.log(
` Position ${i}: ${ok ? "✓" : "✗"} expected "${expectedOrder[i]}", got "${actual}"`,
);
}
// 8b. Verify prices (by description)
log("VERIFY PRICES", "");
let pricesOk = true;
for (const item of newItems) {
const orig = priceSnap.get(item.forecastDescription);
if (!orig) continue;
if (item.revenue !== orig.rev || item.cost !== orig.cost) {
pricesOk = false;
console.log(
` ✗ "${item.forecastDescription}": rev ${orig.rev}${item.revenue}, cost ${orig.cost}${item.cost}`,
);
}
}
if (pricesOk) console.log(" ✓ All prices preserved.");
// 8c. Verify cancellation data — match by description since IDs may have changed
let cancelOk = true;
if (procProducts.length > 0) {
log(
"VERIFY CANCELLATION",
"Checking cancellation data on procurement products after PUT...",
);
const finalProc = await cw.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
);
// Track by procIdsToClean for cleanup
for (const p of finalProc.data) {
if (!procIdsToClean.includes(p.id)) procIdsToClean.push(p.id);
}
for (const pp of finalProc.data) {
const orig = beforeByDesc.get(pp.description);
if (!orig) {
console.log(
` ? Proc ${pp.id} "${pp.description}" — no matching pre-PUT record`,
);
continue;
}
const cancelledMatch =
pp.cancelledFlag === orig.cancelledFlag &&
pp.quantityCancelled === orig.quantityCancelled &&
(pp.cancelledReason ?? "") === (orig.cancelledReason ?? "");
if (!cancelledMatch) {
cancelOk = false;
console.log(
` ✗ Proc ${pp.id} "${pp.description}": CANCELLATION DATA CHANGED\n` +
` Before: cancelled=${orig.cancelledFlag} qty=${orig.quantityCancelled} reason="${orig.cancelledReason ?? ""}"\n` +
` After: cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`,
);
} else {
console.log(
` ✓ Proc ${pp.id} "${pp.description}": cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`,
);
}
}
}
// ── Summary ─────────────────────────────────────────────────────────────
log("SUMMARY", "");
console.log(
" Order correct: ",
orderOk ? "✓ PASS" : "✗ FAIL",
);
console.log(
" Prices preserved: ",
pricesOk ? "✓ PASS" : "✗ FAIL",
);
console.log(
" Proc IDs survived PUT: ",
procSurvived ? "✓ PASS" : "✗ FAIL",
);
console.log(
" Procurement remap: ",
remapOk ? "✓ PASS" : "✗ FAIL (skipped or failed)",
);
console.log(
" Cancellation data preserved:",
cancelOk ? "✓ PASS" : "✗ FAIL",
);
const allPass = orderOk && pricesOk && procSurvived && remapOk && cancelOk;
log("RESULT", allPass ? "✓ ALL TESTS PASSED" : "✗ SOME TESTS FAILED");
} finally {
// ── Cleanup ─────────────────────────────────────────────────────────────
log("CLEANUP", "Deleting procurement products...");
for (const id of procIdsToClean) {
try {
await cw.delete(`/procurement/products/${id}`);
} catch {}
}
log("CLEANUP", `Deleted ${procIdsToClean.length} procurement products.`);
log("CLEANUP", `Deleting opportunity ${oppId}...`);
try {
await cw.delete(`/sales/opportunities/${oppId}`);
log("CLEANUP", "✓ Done.");
} catch (e: any) {
log("CLEANUP", `${e.response?.status ?? e.message}`);
}
}
}
main().catch((err) => {
console.error("\n[FATAL]", err.response?.data ?? err.message);
process.exit(1);
});
+3 -3
View File
@@ -243,9 +243,9 @@ describe("catalogCategories", () => {
// -------------------------------------------------------------------
describe("matchesEcosystem()", () => {
test("matches Ubiquiti Network-Switch to Networking", () => {
expect(
matchesEcosystem("Networking", "Ubiquiti", "Network-Switch"),
).toBe(true);
expect(matchesEcosystem("Networking", "Ubiquiti", "Network-Switch")).toBe(
true,
);
});
test("matches Uniview Surveillance-CamerasIP to Video Surveillance", () => {
+13 -2
View File
@@ -35,7 +35,15 @@ describe("memberCache", () => {
test("stores and retrieves members", async () => {
const members = new Collection<string, CWMember>();
members.set("jroberts", buildTestMember());
members.set("asmith", buildTestMember({ id: 20, identifier: "asmith", firstName: "Alice", lastName: "Smith" }));
members.set(
"asmith",
buildTestMember({
id: 20,
identifier: "asmith",
firstName: "Alice",
lastName: "Smith",
}),
);
await setMemberCache(members);
const cached = await getMemberCache();
@@ -67,7 +75,10 @@ describe("memberCache", () => {
test("falls back to identifier if name parts are empty", async () => {
const members = new Collection<string, CWMember>();
members.set("empty", buildTestMember({ identifier: "empty", firstName: "", lastName: "" }));
members.set(
"empty",
buildTestMember({ identifier: "empty", firstName: "", lastName: "" }),
);
await setMemberCache(members);
expect(resolveMemberName("empty")).toBe("empty");