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
+93 -52
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,16 +406,36 @@ export class OpportunityController {
}
}
const controllers = (forecast.forecastItems ?? [])
.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
.map((item) => {
const ctrl = new ForecastProductController(item);
const procData = cancellationMap.get(item.id);
if (procData) {
ctrl.applyCancellationData(procData as any);
}
return ctrl;
});
// 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) {
ctrl.applyCancellationData(procData as any);
}
return ctrl;
});
// Enrich with internal inventory data from local CatalogItem DB
const catalogCwIds = controllers
@@ -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
>;
// Persist the sequence locally
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: orderedIds },
});
this.productSequence = orderedIds;
// 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,
});
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 ?? [],