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
+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,
+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 ?? [],
@@ -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",
],
},
],
},