30b408e0db
- 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
360 lines
11 KiB
TypeScript
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;
|
|
},
|
|
};
|