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:
@@ -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 ?? [],
|
||||
|
||||
Reference in New Issue
Block a user