Files
optima/src/modules/cw-utils/opportunities/opportunities.ts
T
HoloPanio 30b408e0db 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
2026-03-01 18:01:02 -06:00

360 lines
11 KiB
TypeScript

import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWOpportunity,
CWOpportunitySummary,
CWForecast,
CWForecastItem,
CWForecastItemCreate,
CWOpportunityNote,
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
CWOpportunityContact,
} from "./opportunity.types";
export const opportunityCw = {
/**
* Count Opportunities
*
* Returns the total number of opportunities in ConnectWise.
* Optionally accepts CW conditions string for filtered counts.
*/
countItems: async (conditions?: string): Promise<number> => {
const query = conditions
? `/sales/opportunities/count?conditions=${encodeURIComponent(conditions)}`
: "/sales/opportunities/count";
const response = await connectWiseApi.get(query);
return response.data.count;
},
/**
* Fetch All Opportunity Summaries
*
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
* Paginates through all opportunities.
*/
fetchAllSummaries: async (): Promise<
Collection<number, CWOpportunitySummary>
> => {
const allItems = new Collection<number, CWOpportunitySummary>();
const pageSize = 1000;
const count = await opportunityCw.countItems();
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const response = await connectWiseApi.get(
`/sales/opportunities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
);
const items: CWOpportunitySummary[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
/**
* Fetch All Opportunities (Full)
*
* Fetches all opportunities with complete data. Paginates through
* the full list.
*/
fetchAll: async (
conditions?: string,
): Promise<Collection<number, CWOpportunity>> => {
const allItems = new Collection<number, CWOpportunity>();
const pageSize = 1000;
const count = await opportunityCw.countItems(conditions);
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const conditionsParam = conditions
? `&conditions=${encodeURIComponent(conditions)}`
: "";
const response = await connectWiseApi.get(
`/sales/opportunities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
);
const items: CWOpportunity[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
/**
* Fetch Single Opportunity
*
* Fetches a single opportunity by its ConnectWise ID.
*/
fetch: async (id: number): Promise<CWOpportunity> => {
const response = await connectWiseApi.get(`/sales/opportunities/${id}`);
return response.data;
},
/**
* Fetch Opportunities by Company
*
* Fetches all opportunities associated with a specific ConnectWise company ID.
*/
fetchByCompany: async (
cwCompanyId: number,
): Promise<Collection<number, CWOpportunity>> => {
return opportunityCw.fetchAll(`company/id=${cwCompanyId}`);
},
/**
* Fetch Opportunity Products
*
* Fetches the full forecast object (products, revenue summaries, totals)
* for a given opportunity.
*/
fetchProducts: async (opportunityId: number): Promise<CWForecast> => {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/forecast`,
);
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
*
* 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 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]!);
},
/**
* Fetch Opportunity Notes
*
* Fetches notes associated with a given opportunity.
*/
fetchNotes: async (opportunityId: number): Promise<CWOpportunityNote[]> => {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/notes`,
);
return response.data;
},
/**
* Fetch Single Note
*
* Fetches a single note by its ID on the given opportunity.
*/
fetchNote: async (
opportunityId: number,
noteId: number,
): Promise<CWOpportunityNote> => {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
);
return response.data;
},
/**
* Create Note
*
* Creates a new note on the given opportunity.
*/
createNote: async (
opportunityId: number,
data: CWOpportunityNoteCreate,
): Promise<CWOpportunityNote> => {
const response = await connectWiseApi.post(
`/sales/opportunities/${opportunityId}/notes`,
data,
);
return response.data;
},
/**
* Update Note
*
* Updates an existing note on the given opportunity.
*/
updateNote: async (
opportunityId: number,
noteId: number,
data: CWOpportunityNoteUpdate,
): Promise<CWOpportunityNote> => {
const response = await connectWiseApi.patch(
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
Object.entries(data).map(([key, value]) => ({
op: "replace",
path: key,
value,
})),
);
return response.data;
},
/**
* Delete Note
*
* Deletes a note from the given opportunity.
*/
deleteNote: async (opportunityId: number, noteId: number): Promise<void> => {
await connectWiseApi.delete(
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
);
},
/**
* Fetch Opportunity Contacts
*
* Fetches contacts associated with a given opportunity.
*/
fetchContacts: async (
opportunityId: number,
): Promise<CWOpportunityContact[]> => {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/contacts`,
);
return response.data;
},
/**
* Fetch Procurement Products
*
* Fetches procurement product records linked to an opportunity.
* These contain cancellation data (cancelledFlag, cancelledReason, etc.)
* that the forecast endpoint does not provide.
*/
fetchProcurementProducts: async (
opportunityId: number,
): Promise<Record<string, unknown>[]> => {
const response = await connectWiseApi.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
);
return response.data;
},
};