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 => { 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 > => { const allItems = new Collection(); 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> => { const allItems = new Collection(); 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 => { 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> => { 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 => { 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 => { 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), })); // 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, ): Promise => { 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>, ): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { await connectWiseApi.delete( `/sales/opportunities/${opportunityId}/notes/${noteId}`, ); }, /** * Fetch Opportunity Contacts * * Fetches contacts associated with a given opportunity. */ fetchContacts: async ( opportunityId: number, ): Promise => { 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[]> => { const response = await connectWiseApi.get( `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`, ); return response.data; }, };