feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests
New features: - ActivityController and manager for CW sales activities (CRUD) - ForecastProductController for opportunity forecast/product lines - CW member cache with dual-layer (in-memory + Redis) resolution - Catalog category/subcategory/ecosystem taxonomy module - Quote statuses type definitions with CW mapping - User-defined fields (UDF) module with cache and event refresh - Company sites CW module with serialization - Procurement manager filters (category, ecosystem, manufacturer, price, stock) - Opportunity notes CRUD and product line management via CW API - Opportunity type definitions endpoint Updates: - OpportunityController: CW refresh, company hydration, activities, custom fields - UserController: cwIdentifier field for CW member linking - CatalogItemController: category/subcategory fields from CW - PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions - API routes: procurement categories/filters, sales notes/products, opportunity types - Global events: UDF and member refresh intervals on startup Tests (414 passing): - ActivityController, ForecastProductController, OpportunityController unit tests - UserController cwIdentifier tests - catalogCategories, companySites, memberCache, procurement module tests - activityTypes, opportunityTypes, quoteStatuses type tests - permissionNodes subCategories and getAllPermissionNodes tests - Updated test setup with redis mock, API method mocks, and builder helpers
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Catalog Categories & Ecosystems
|
||||
*
|
||||
* This module defines the complete category/subcategory hierarchy and
|
||||
* ecosystem decision trees used for product filtering in the UI.
|
||||
*
|
||||
* --- Terminology ---
|
||||
*
|
||||
* Category: Top-level CW category (e.g. "Technology", "Field", "General").
|
||||
* A category is NEVER a subcategory.
|
||||
*
|
||||
* Subcategory: The CW subcategory name stored on each catalog item.
|
||||
* At the second level of the tree, if there are no children
|
||||
* beneath it then the node name IS the subcategory.
|
||||
* If children exist, the second-level node is an *umbrella*
|
||||
* that groups related subcategories — the children are the
|
||||
* actual subcategory names.
|
||||
*
|
||||
* Ecosystem: A cross-cutting product grouping defined by manufacturer +
|
||||
* category + subcategory-prefix rules. Ecosystems let the UI
|
||||
* present a "Networking" or "Video Surveillance" view that
|
||||
* spans manufacturers regardless of where CW filed them.
|
||||
*
|
||||
* --- Data shapes ---
|
||||
*
|
||||
* SubcategoryNode – a leaf: `{ name, cwId? }`
|
||||
* CategoryGroup – an umbrella with children: `{ name, children[] }`
|
||||
* CategoryEntry – either a leaf OR a group at the 2nd level
|
||||
* TopLevelCategory – `{ name, cwId?, entries[] }`
|
||||
*
|
||||
* The `CATEGORY_TREE` export is the single source of truth; helpers derive
|
||||
* flat lists, lookup maps, and search predicates from it.
|
||||
*/
|
||||
|
||||
// ─── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SubcategoryNode {
|
||||
/** The exact CW subcategory name */
|
||||
name: string;
|
||||
/** CW subcategory id (optional, for reference) */
|
||||
cwId?: number;
|
||||
}
|
||||
|
||||
export interface CategoryGroup {
|
||||
/** Display name of the umbrella (e.g. "Network", "Cables", "AlarmBurg") */
|
||||
name: string;
|
||||
/** The subcategories that belong to this umbrella */
|
||||
children: SubcategoryNode[];
|
||||
}
|
||||
|
||||
/** A second-level entry is either a direct subcategory or an umbrella group */
|
||||
export type CategoryEntry = SubcategoryNode | CategoryGroup;
|
||||
|
||||
export interface TopLevelCategory {
|
||||
/** The CW category name */
|
||||
name: string;
|
||||
/** CW category id (optional, for reference) */
|
||||
cwId?: number;
|
||||
/** Second-level entries under this category */
|
||||
entries: CategoryEntry[];
|
||||
}
|
||||
|
||||
/** Helper type guard */
|
||||
export function isCategoryGroup(entry: CategoryEntry): entry is CategoryGroup {
|
||||
return "children" in entry;
|
||||
}
|
||||
|
||||
// ─── Ecosystem types ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface EcosystemManufacturer {
|
||||
/** Manufacturer name as stored in CW */
|
||||
name: string;
|
||||
/** CW manufacturer id */
|
||||
cwId?: number;
|
||||
/** Which CW category these products fall under */
|
||||
category: string;
|
||||
/** Subcategory prefix — matches any subcategory starting with this string */
|
||||
subcategoryPrefix: string;
|
||||
}
|
||||
|
||||
export interface Ecosystem {
|
||||
/** Display name (e.g. "Networking", "Video Surveillance") */
|
||||
name: string;
|
||||
/** Manufacturers that belong to this ecosystem */
|
||||
manufacturers: EcosystemManufacturer[];
|
||||
}
|
||||
|
||||
// ─── Category Tree ───────────────────────────────────────────────────────────
|
||||
|
||||
export const CATEGORY_TREE: TopLevelCategory[] = [
|
||||
{
|
||||
name: "Technology",
|
||||
cwId: 18,
|
||||
entries: [
|
||||
{ name: "GeneralEquip", cwId: 57 },
|
||||
{ name: "Home Entertainment", cwId: 114 },
|
||||
{ name: "Monitor", cwId: 115 },
|
||||
{ name: "Printers", cwId: 120 },
|
||||
{ name: "Storage", cwId: 108 },
|
||||
{
|
||||
name: "Network",
|
||||
children: [
|
||||
{ name: "Network-Other", cwId: 174 },
|
||||
{ name: "Network-Router", cwId: 119 },
|
||||
{ name: "Network-Switch", cwId: 112 },
|
||||
{ name: "Network-Wireless", cwId: 111 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Computer",
|
||||
children: [
|
||||
{ name: "Computer-Components", cwId: 109 },
|
||||
{ name: "Computer-Desktop", cwId: 106 },
|
||||
{ name: "Computer-Laptop", cwId: 107 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Recurring",
|
||||
children: [
|
||||
{ name: "Recurring - Online", cwId: 83 },
|
||||
{ name: "Recurring - Other", cwId: 84 },
|
||||
{ name: "Recurring - Protection", cwId: 81 },
|
||||
{ name: "Recurring - Telephone", cwId: 133 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Telephone",
|
||||
children: [
|
||||
{ name: "Tele-HSet-Digital", cwId: 116 },
|
||||
{ name: "Tele-HSet-IP", cwId: 206 },
|
||||
{ name: "Tele-HSet-SLT" },
|
||||
{ name: "Tele-Misc", cwId: 75 },
|
||||
{ name: "Tele-Paging", cwId: 76 },
|
||||
{ name: "Tele-SystemCards", cwId: 135 },
|
||||
{ name: "Tele-Systems", cwId: 78 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "General",
|
||||
cwId: 25,
|
||||
entries: [
|
||||
{ name: "Batteries", cwId: 80 },
|
||||
{ name: "Battery Backups", cwId: 144 },
|
||||
{ name: "BulkWire", cwId: 200 },
|
||||
{
|
||||
name: "Cables",
|
||||
children: [
|
||||
{ name: "Cables-Adapters", cwId: 182 },
|
||||
{ name: "Cables-HDMI", cwId: 176 },
|
||||
{ name: "Cables-Network", cwId: 87 },
|
||||
{ name: "Cables-Other", cwId: 177 },
|
||||
{ name: "Cables-USB", cwId: 178 },
|
||||
{ name: "Cables-VGA", cwId: 179 },
|
||||
],
|
||||
},
|
||||
{ name: "Elec Cords & Adapters", cwId: 142 },
|
||||
{ name: "Enclosures", cwId: 141 },
|
||||
{ name: "PowerSupply", cwId: 167 },
|
||||
{
|
||||
name: "RackEquip",
|
||||
children: [
|
||||
{ name: "RackEquip-Rack", cwId: 143 },
|
||||
{ name: "RackEquip-Shelves", cwId: 190 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Field",
|
||||
cwId: 28,
|
||||
entries: [
|
||||
{ name: "Conduit" },
|
||||
{ name: "Electric", cwId: 199 },
|
||||
{ name: "GateControl", cwId: 45 },
|
||||
{ name: "Locksets" },
|
||||
{ name: "Other", cwId: 46 },
|
||||
{ name: "Relays", cwId: 168 },
|
||||
{
|
||||
name: "AccessControl",
|
||||
children: [
|
||||
{ name: "AccessControl-Controllers", cwId: 137 },
|
||||
{ name: "AccessControl-Credential", cwId: 183 },
|
||||
{ name: "AccessControl-LockDevices", cwId: 138 },
|
||||
{ name: "AccessControl-Other", cwId: 44 },
|
||||
{ name: "AccessControl-Readers", cwId: 136 },
|
||||
{ name: "AccessControl-VideoEntry", cwId: 139 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AlarmBurg",
|
||||
children: [
|
||||
{ name: "AlarmBurg-Communicators", cwId: 96 },
|
||||
{ name: "AlarmBurg-Keypads", cwId: 93 },
|
||||
{ name: "AlarmBurg-Modules", cwId: 140 },
|
||||
{ name: "AlarmBurg-Other", cwId: 92 },
|
||||
{ name: "AlarmBurg-Panels", cwId: 42 },
|
||||
{ name: "AlarmBurg-Sensors-Wireless", cwId: 147 },
|
||||
{ name: "AlarmBurg-Sensors-Wired", cwId: 146 },
|
||||
{ name: "AlarmBurg-Siren", cwId: 145 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AlarmFire",
|
||||
children: [
|
||||
{ name: "AlarmFire-Communicators", cwId: 97 },
|
||||
{ name: "AlarmFire-Devices", cwId: 169 },
|
||||
{ name: "AlarmFire-Modules", cwId: 170 },
|
||||
{ name: "AlarmFire-Other", cwId: 98 },
|
||||
{ name: "AlarmFire-Panels", cwId: 95 },
|
||||
{ name: "AlarmFire-Sensors", cwId: 94 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Automation",
|
||||
children: [
|
||||
{ name: "Automation-General", cwId: 99 },
|
||||
{ name: "Automation-HVAC", cwId: 181 },
|
||||
{ name: "Automation-Lights", cwId: 180 },
|
||||
{ name: "Automation-Locks", cwId: 192 },
|
||||
{ name: "Automation-Thermostat" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AV",
|
||||
children: [
|
||||
{ name: "AV-Adapters&Cables", cwId: 171 },
|
||||
{ name: "AV-Components", cwId: 172 },
|
||||
{ name: "AV-Mounts", cwId: 191 },
|
||||
{ name: "AV-Other", cwId: 184 },
|
||||
{ name: "AV-Speakers", cwId: 173 },
|
||||
{ name: "AV-Television", cwId: 175 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "StrCbl",
|
||||
children: [
|
||||
{ name: "StrCbl-Jacks", cwId: 186 },
|
||||
{ name: "StrCbl-PatchPanel", cwId: 187 },
|
||||
{ name: "StrCbl-Plates", cwId: 185 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Surveillance",
|
||||
children: [
|
||||
{ name: "Surveillance-Accs", cwId: 90 },
|
||||
{ name: "Surveillance-CamerasAnalog", cwId: 89 },
|
||||
{ name: "Surveillance-CamerasIP", cwId: 88 },
|
||||
{ name: "Surveillance-NVR", cwId: 43 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Ecosystem Tree ──────────────────────────────────────────────────────────
|
||||
|
||||
export const ECOSYSTEM_TREE: Ecosystem[] = [
|
||||
{
|
||||
name: "Networking",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Ubiquiti",
|
||||
cwId: 248,
|
||||
category: "Technology",
|
||||
subcategoryPrefix: "Network-",
|
||||
},
|
||||
{
|
||||
name: "TP-Link",
|
||||
cwId: 259,
|
||||
category: "Technology",
|
||||
subcategoryPrefix: "Network-",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Video Surveillance",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Uniview",
|
||||
cwId: 239,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
{
|
||||
name: "Hikvision",
|
||||
cwId: 299,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
{
|
||||
name: "Alarm.com",
|
||||
cwId: 294,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Burg/Alarm",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Qolsys",
|
||||
cwId: 376,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "AlarmBurg-",
|
||||
},
|
||||
{
|
||||
name: "DSC",
|
||||
cwId: 287,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "AlarmBurg-",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Derived helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a flat list of all subcategory names under a given category.
|
||||
*/
|
||||
export function getSubcategoriesForCategory(categoryName: string): string[] {
|
||||
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
|
||||
if (!category) return [];
|
||||
|
||||
const subcats: string[] = [];
|
||||
for (const entry of category.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
for (const child of entry.children) {
|
||||
subcats.push(child.name);
|
||||
}
|
||||
} else {
|
||||
subcats.push(entry.name);
|
||||
}
|
||||
}
|
||||
return subcats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all subcategory names under a given umbrella group within a category.
|
||||
* e.g. getSubcategoriesForGroup("Field", "AlarmBurg") → ["AlarmBurg-Communicators", ...]
|
||||
*/
|
||||
export function getSubcategoriesForGroup(
|
||||
categoryName: string,
|
||||
groupName: string,
|
||||
): string[] {
|
||||
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
|
||||
if (!category) return [];
|
||||
|
||||
const group = category.entries.find(
|
||||
(e) => isCategoryGroup(e) && e.name === groupName,
|
||||
);
|
||||
if (!group || !isCategoryGroup(group)) return [];
|
||||
|
||||
return group.children.map((c) => c.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all top-level category names.
|
||||
*/
|
||||
export function getCategoryNames(): string[] {
|
||||
return CATEGORY_TREE.map((c) => c.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the umbrella group name for a given subcategory, or null if it's a
|
||||
* direct entry (not under an umbrella).
|
||||
*/
|
||||
export function getGroupForSubcategory(
|
||||
subcategoryName: string,
|
||||
): { category: string; group: string } | null {
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
if (entry.children.some((c) => c.name === subcategoryName)) {
|
||||
return { category: cat.name, group: entry.name };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full tree serialized for the API / UI consumption.
|
||||
* Each top-level category includes its entries, with umbrella groups
|
||||
* expanded to show children.
|
||||
*/
|
||||
export function serializeCategoryTree() {
|
||||
return CATEGORY_TREE.map((cat) => ({
|
||||
name: cat.name,
|
||||
cwId: cat.cwId ?? null,
|
||||
entries: cat.entries.map((entry) => {
|
||||
if (isCategoryGroup(entry)) {
|
||||
return {
|
||||
type: "group" as const,
|
||||
name: entry.name,
|
||||
subcategories: entry.children.map((c) => ({
|
||||
name: c.name,
|
||||
cwId: c.cwId ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "subcategory" as const,
|
||||
name: entry.name,
|
||||
cwId: (entry as SubcategoryNode).cwId ?? null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ecosystem tree serialized for the API / UI consumption.
|
||||
*/
|
||||
export function serializeEcosystemTree() {
|
||||
return ECOSYSTEM_TREE.map((eco) => ({
|
||||
name: eco.name,
|
||||
manufacturers: eco.manufacturers.map((m) => ({
|
||||
name: m.name,
|
||||
cwId: m.cwId ?? null,
|
||||
category: m.category,
|
||||
subcategoryPrefix: m.subcategoryPrefix,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a flat list of every known subcategory name across all categories.
|
||||
*/
|
||||
export function getAllSubcategoryNames(): string[] {
|
||||
const names: string[] = [];
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
for (const child of entry.children) {
|
||||
names.push(child.name);
|
||||
}
|
||||
} else {
|
||||
names.push(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a CW subcategory name, resolves which top-level category it belongs to.
|
||||
*/
|
||||
export function getCategoryForSubcategory(
|
||||
subcategoryName: string,
|
||||
): string | null {
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
if (entry.children.some((c) => c.name === subcategoryName)) {
|
||||
return cat.name;
|
||||
}
|
||||
} else if (entry.name === subcategoryName) {
|
||||
return cat.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a CW manufacturer name, returns which ecosystems it belongs to.
|
||||
*/
|
||||
export function getEcosystemsForManufacturer(
|
||||
manufacturerName: string,
|
||||
): string[] {
|
||||
return ECOSYSTEM_TREE.filter((eco) =>
|
||||
eco.manufacturers.some(
|
||||
(m) => m.name.toLowerCase() === manufacturerName.toLowerCase(),
|
||||
),
|
||||
).map((eco) => eco.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a catalog item (by manufacturer + subcategory) matches a given ecosystem.
|
||||
*/
|
||||
export function matchesEcosystem(
|
||||
ecosystemName: string,
|
||||
manufacturer: string | null,
|
||||
subcategory: string | null,
|
||||
): boolean {
|
||||
const eco = ECOSYSTEM_TREE.find((e) => e.name === ecosystemName);
|
||||
if (!eco) return false;
|
||||
|
||||
return eco.manufacturers.some(
|
||||
(m) =>
|
||||
m.name.toLowerCase() === (manufacturer ?? "").toLowerCase() &&
|
||||
(subcategory ?? "").startsWith(m.subcategoryPrefix),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivitySummary,
|
||||
CWCreateActivity,
|
||||
CWPatchOperation,
|
||||
} from "./activity.types";
|
||||
|
||||
export const activityCw = {
|
||||
/**
|
||||
* Count Activities
|
||||
*
|
||||
* Returns the total number of activities in ConnectWise.
|
||||
* Optionally accepts CW conditions string for filtered counts.
|
||||
*/
|
||||
countItems: async (conditions?: string): Promise<number> => {
|
||||
const query = conditions
|
||||
? `/sales/activities/count?conditions=${encodeURIComponent(conditions)}`
|
||||
: "/sales/activities/count";
|
||||
const response = await connectWiseApi.get(query);
|
||||
return response.data.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activity Summaries
|
||||
*
|
||||
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
|
||||
* Paginates through all activities.
|
||||
*/
|
||||
fetchAllSummaries: async (): Promise<
|
||||
Collection<number, CWActivitySummary>
|
||||
> => {
|
||||
const allItems = new Collection<number, CWActivitySummary>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await activityCw.countItems();
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/activities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
|
||||
);
|
||||
const items: CWActivitySummary[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activities (Full)
|
||||
*
|
||||
* Fetches all activities with complete data. Paginates through
|
||||
* the full list. Optionally accepts CW conditions string for filtering.
|
||||
*/
|
||||
fetchAll: async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
const allItems = new Collection<number, CWActivity>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await activityCw.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/activities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||
);
|
||||
const items: CWActivity[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Single Activity
|
||||
*
|
||||
* Fetches a single activity by its ConnectWise ID.
|
||||
*/
|
||||
fetch: async (id: number): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.get(`/sales/activities/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Company
|
||||
*
|
||||
* Fetches all activities associated with a specific ConnectWise company ID.
|
||||
*/
|
||||
fetchByCompany: async (
|
||||
cwCompanyId: number,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
return activityCw.fetchAll(`company/id=${cwCompanyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Opportunity
|
||||
*
|
||||
* Fetches all activities associated with a specific opportunity ID.
|
||||
*/
|
||||
fetchByOpportunity: async (
|
||||
opportunityId: number,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
return activityCw.fetchAll(`opportunity/id=${opportunityId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Activity
|
||||
*
|
||||
* Creates a new activity in ConnectWise.
|
||||
*/
|
||||
create: async (activity: CWCreateActivity): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.post("/sales/activities", activity);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Activity (PATCH)
|
||||
*
|
||||
* Updates an existing activity using JSON Patch operations.
|
||||
*/
|
||||
update: async (
|
||||
id: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.patch(
|
||||
`/sales/activities/${id}`,
|
||||
operations,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace Activity (PUT)
|
||||
*
|
||||
* Replaces an entire activity record in ConnectWise.
|
||||
*/
|
||||
replace: async (
|
||||
id: number,
|
||||
activity: CWCreateActivity,
|
||||
): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.put(
|
||||
`/sales/activities/${id}`,
|
||||
activity,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Activity
|
||||
*
|
||||
* Deletes an activity by its ConnectWise ID.
|
||||
*/
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await connectWiseApi.delete(`/sales/activities/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
interface CWReference {
|
||||
id: number;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWMemberReference {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWCompanyReference {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWContactReference {
|
||||
id: number;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWActivity {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CWReference;
|
||||
company: CWCompanyReference;
|
||||
contact: CWContactReference;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
status: CWReference;
|
||||
opportunity: CWReference;
|
||||
ticket: CWReference;
|
||||
agreement: CWReference;
|
||||
campaign: CWReference;
|
||||
notes: string;
|
||||
dateStart: string;
|
||||
dateEnd: string;
|
||||
assignTo: CWMemberReference;
|
||||
scheduleStatus: CWReference;
|
||||
reminder: CWReference;
|
||||
where: CWReference;
|
||||
notifyFlag: boolean;
|
||||
mobileGuid: string;
|
||||
currency: CWReference;
|
||||
customFields: CWActivityCustomField[];
|
||||
_info: CWActivityInfo;
|
||||
}
|
||||
|
||||
export interface CWActivityCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
entryMethod: string;
|
||||
numberOfDecimals: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface CWActivityInfo {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
dateEntered: string;
|
||||
enteredBy: string;
|
||||
}
|
||||
|
||||
export interface CWActivitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWCreateActivity {
|
||||
name: string;
|
||||
type?: { id: number };
|
||||
company?: { id: number };
|
||||
contact?: { id: number };
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
status?: { id: number };
|
||||
opportunity?: { id: number };
|
||||
ticket?: { id: number };
|
||||
agreement?: { id: number };
|
||||
campaign?: { id: number };
|
||||
notes?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
assignTo?: { id: number };
|
||||
scheduleStatus?: { id: number };
|
||||
reminder?: { id: number };
|
||||
where?: { id: number };
|
||||
notifyFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface CWUpdateActivity {
|
||||
name?: string;
|
||||
type?: { id: number };
|
||||
company?: { id: number };
|
||||
contact?: { id: number };
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
status?: { id: number };
|
||||
opportunity?: { id: number };
|
||||
ticket?: { id: number };
|
||||
agreement?: { id: number };
|
||||
campaign?: { id: number };
|
||||
notes?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
assignTo?: { id: number };
|
||||
scheduleStatus?: { id: number };
|
||||
reminder?: { id: number };
|
||||
where?: { id: number };
|
||||
notifyFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface CWPatchOperation {
|
||||
op: "replace" | "add" | "remove";
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity, CWCreateActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Create a new activity in ConnectWise.
|
||||
*
|
||||
* @param activity - The activity data to create
|
||||
* @returns The newly created CW activity object
|
||||
* @throws GenericError if the creation fails
|
||||
*/
|
||||
export const createActivity = async (
|
||||
activity: CWCreateActivity,
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.create(activity);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error("Error creating activity:", errBody);
|
||||
throw new GenericError({
|
||||
name: "CreateActivityError",
|
||||
message: "Failed to create activity in ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Fetch a single activity by its ConnectWise ID.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID
|
||||
* @returns The full CW activity object
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchActivity = async (
|
||||
cwActivityId: number,
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.fetch(cwActivityId);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(`Error fetching activity with ID ${cwActivityId}:`, errBody);
|
||||
throw new GenericError({
|
||||
name: "FetchActivityError",
|
||||
message: `Failed to fetch activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Fetch all activities from ConnectWise with optional conditions.
|
||||
*
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns A Collection of CW activities keyed by their ID
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchAllActivities = async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
try {
|
||||
return await activityCw.fetchAll(conditions);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error("Error fetching all activities:", errBody);
|
||||
throw new GenericError({
|
||||
name: "FetchAllActivitiesError",
|
||||
message: "Failed to fetch activities from ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export { activityCw } from "./activities";
|
||||
export { fetchActivity } from "./fetchActivity";
|
||||
export { fetchAllActivities } from "./fetchAllActivities";
|
||||
export { createActivity } from "./createActivity";
|
||||
export { updateActivity } from "./updateActivity";
|
||||
|
||||
export type {
|
||||
CWActivity,
|
||||
CWActivitySummary,
|
||||
CWActivityCustomField,
|
||||
CWActivityInfo,
|
||||
CWCreateActivity,
|
||||
CWUpdateActivity,
|
||||
CWPatchOperation,
|
||||
} from "./activity.types";
|
||||
@@ -0,0 +1,29 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity, CWPatchOperation } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Update an existing activity in ConnectWise using JSON Patch operations.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID to update
|
||||
* @param operations - Array of JSON Patch operations to apply
|
||||
* @returns The updated CW activity object
|
||||
* @throws GenericError if the update fails
|
||||
*/
|
||||
export const updateActivity = async (
|
||||
cwActivityId: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.update(cwActivityId, operations);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(`Error updating activity with ID ${cwActivityId}:`, errBody);
|
||||
throw new GenericError({
|
||||
name: "UpdateActivityError",
|
||||
message: `Failed to update activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
|
||||
export interface CWMember {
|
||||
id: number;
|
||||
identifier: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
officeEmail: string;
|
||||
inactiveFlag: boolean;
|
||||
_info: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch All CW Members
|
||||
*
|
||||
* Fetches every member from ConnectWise using pagination and returns them
|
||||
* in a Collection keyed by their identifier (e.g. "jroberts").
|
||||
*
|
||||
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const fetchAllCwMembers = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const { data: countData } = await connectWiseApi.get("/system/members/count");
|
||||
const totalPages = Math.ceil(countData.count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const { data } = await connectWiseApi.get<CWMember[]>(
|
||||
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
|
||||
);
|
||||
|
||||
for (const member of data) {
|
||||
members.set(member.identifier, member);
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find CW Member Identifier by Email
|
||||
*
|
||||
* Looks up a ConnectWise member whose `officeEmail` matches the provided
|
||||
* email address (case-insensitive) and returns their `identifier` string
|
||||
* (e.g. "jroberts"). Returns `null` if no match is found.
|
||||
*
|
||||
* @param email - The email address to search for
|
||||
* @param members - Optional pre-fetched member collection to search against (avoids extra API call)
|
||||
* @returns {Promise<string | null>} The CW identifier or null
|
||||
*/
|
||||
export const findCwIdentifierByEmail = async (
|
||||
email: string,
|
||||
members?: Collection<string, CWMember>,
|
||||
): Promise<string | null> => {
|
||||
const allMembers = members ?? (await fetchAllCwMembers());
|
||||
const normalised = email.toLowerCase();
|
||||
|
||||
const match = allMembers.find(
|
||||
(m) => m.officeEmail?.toLowerCase() === normalised,
|
||||
);
|
||||
|
||||
return match?.identifier ?? null;
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { prisma } from "../../../constants";
|
||||
import { redis } from "../../../constants";
|
||||
import { CWMember } from "./fetchAllMembers";
|
||||
|
||||
const REDIS_KEY = "cw:members";
|
||||
|
||||
export interface ResolvedMember {
|
||||
/** Local database user ID (null if no matching local user) */
|
||||
id: string | null;
|
||||
/** CW member identifier (e.g. "jroberts") */
|
||||
identifier: string;
|
||||
/** Full name resolved from CW member cache, or raw identifier as fallback */
|
||||
name: string;
|
||||
/** ConnectWise member ID */
|
||||
cwMemberId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW Member Cache
|
||||
*
|
||||
* Dual-layer cache (in-memory + Redis) of ConnectWise members keyed by
|
||||
* their identifier (e.g. "jroberts"). Populated by `refreshCwIdentifiers`
|
||||
* on startup and every 30 minutes thereafter.
|
||||
*/
|
||||
let memberCache = new Collection<string, CWMember>();
|
||||
|
||||
/**
|
||||
* Set the member cache contents.
|
||||
*
|
||||
* Replaces both the in-memory Collection and the Redis snapshot.
|
||||
*
|
||||
* @param members - Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const setMemberCache = async (members: Collection<string, CWMember>) => {
|
||||
memberCache = members;
|
||||
await redis.set(REDIS_KEY, JSON.stringify([...members.values()]));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current member cache.
|
||||
*
|
||||
* Returns the in-memory Collection. If empty, attempts to hydrate from Redis
|
||||
* first. Returns whatever is available (may be empty if Redis is also cold).
|
||||
*/
|
||||
export const getMemberCache = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
if (memberCache.size > 0) return memberCache;
|
||||
|
||||
const stored = await redis.get(REDIS_KEY);
|
||||
if (stored) {
|
||||
const parsed: CWMember[] = JSON.parse(stored);
|
||||
memberCache = new Collection(parsed.map((m) => [m.identifier, m]));
|
||||
}
|
||||
|
||||
return memberCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve CW Identifier to Full Name
|
||||
*
|
||||
* Looks up a ConnectWise member by their identifier in the in-memory cache
|
||||
* and returns their full name. Falls back to the raw identifier if not found.
|
||||
*
|
||||
* @param identifier - The CW member identifier (e.g. "jroberts")
|
||||
* @returns The member's full name (e.g. "John Roberts") or the raw identifier
|
||||
*/
|
||||
export const resolveMemberName = (identifier: string): string => {
|
||||
const member = memberCache.get(identifier);
|
||||
if (!member) return identifier;
|
||||
return `${member.firstName} ${member.lastName}`.trim() || identifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve CW Identifier to Full Member Info
|
||||
*
|
||||
* Looks up a ConnectWise member by their identifier in the in-memory cache
|
||||
* and cross-references with the local database to return a complete member
|
||||
* reference including local user ID, CW identifier, full name, and CW member ID.
|
||||
*
|
||||
* @param identifier - The CW member identifier (e.g. "jroberts")
|
||||
* @returns {Promise<ResolvedMember>} Resolved member info
|
||||
*/
|
||||
export const resolveMember = async (
|
||||
identifier: string,
|
||||
): Promise<ResolvedMember> => {
|
||||
const cwMember = memberCache.get(identifier);
|
||||
const name = cwMember
|
||||
? `${cwMember.firstName} ${cwMember.lastName}`.trim() || identifier
|
||||
: identifier;
|
||||
|
||||
const localUser = await prisma.user.findFirst({
|
||||
where: { cwIdentifier: identifier },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: localUser?.id ?? null,
|
||||
identifier,
|
||||
name,
|
||||
cwMemberId: cwMember?.id ?? null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { connectWiseApi, prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { fetchAllCwMembers, findCwIdentifierByEmail } from "./fetchAllMembers";
|
||||
import { setMemberCache } from "./memberCache";
|
||||
|
||||
/**
|
||||
* Refresh CW Identifiers
|
||||
*
|
||||
* Fetches all CW members and all users from the database, then updates
|
||||
* each user's `cwIdentifier` field by matching their email to a CW member's
|
||||
* `officeEmail`. Only users whose identifier has changed (or was previously
|
||||
* null) are updated to avoid unnecessary writes.
|
||||
*
|
||||
* Also refreshes the in-memory member cache used for name resolution.
|
||||
*/
|
||||
export const refreshCwIdentifiers = async () => {
|
||||
events.emit("cw:members:refresh:started");
|
||||
|
||||
const allMembers = await fetchAllCwMembers();
|
||||
await setMemberCache(allMembers);
|
||||
const allUsers = await prisma.user.findMany({
|
||||
select: { id: true, email: true, cwIdentifier: true },
|
||||
});
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
await Promise.all(
|
||||
allUsers.map(async (user) => {
|
||||
const identifier = await findCwIdentifierByEmail(user.email, allMembers);
|
||||
|
||||
if (identifier !== user.cwIdentifier) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { cwIdentifier: identifier },
|
||||
});
|
||||
updatedCount++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
events.emit("cw:members:refresh:completed", {
|
||||
totalMembers: allMembers.size,
|
||||
totalUsers: allUsers.length,
|
||||
usersUpdated: updatedCount,
|
||||
});
|
||||
};
|
||||
@@ -3,8 +3,11 @@ import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWOpportunity,
|
||||
CWOpportunitySummary,
|
||||
CWForecast,
|
||||
CWForecastItem,
|
||||
CWOpportunityNote,
|
||||
CWOpportunityNoteCreate,
|
||||
CWOpportunityNoteUpdate,
|
||||
CWOpportunityContact,
|
||||
} from "./opportunity.types";
|
||||
|
||||
@@ -106,14 +109,35 @@ export const opportunityCw = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Forecasts
|
||||
* Fetch Opportunity Products
|
||||
*
|
||||
* Fetches forecast/revenue items for a given opportunity.
|
||||
* Fetches the full forecast object (products, revenue summaries, totals)
|
||||
* for a given opportunity.
|
||||
*/
|
||||
fetchForecasts: async (opportunityId: number): Promise<CWForecastItem[]> => {
|
||||
fetchProducts: async (opportunityId: number): Promise<CWForecast> => {
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Forecast Item
|
||||
*
|
||||
* Updates a single forecast item (product) on an opportunity using PUT.
|
||||
*/
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -129,6 +153,69 @@ export const opportunityCw = {
|
||||
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
|
||||
*
|
||||
@@ -142,4 +229,20 @@ export const opportunityCw = {
|
||||
);
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ interface CWSiteReference {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWCustomField {
|
||||
export interface CWCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
@@ -103,16 +103,72 @@ export interface CWOpportunityInfo {
|
||||
|
||||
export interface CWForecastItem {
|
||||
id: number;
|
||||
forecastDescription: string;
|
||||
opportunity: CWReference;
|
||||
forecastType: string;
|
||||
forecastMonth: string;
|
||||
quantity: number;
|
||||
status: CWReference;
|
||||
catalogItem?: {
|
||||
id: number;
|
||||
identifier: string;
|
||||
_info?: Record<string, string>;
|
||||
};
|
||||
productDescription: string;
|
||||
productClass: string;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
forecastPercentage: number;
|
||||
status: CWReference;
|
||||
includedFlag: boolean;
|
||||
linkedFlag: boolean;
|
||||
margin: number;
|
||||
percentage: number;
|
||||
includeFlag: boolean;
|
||||
quoteWerksQuantity: number;
|
||||
forecastType: string;
|
||||
linkFlag: boolean;
|
||||
recurringRevenue: number;
|
||||
recurringCost: number;
|
||||
cycles: number;
|
||||
recurringFlag: boolean;
|
||||
sequenceNumber: number;
|
||||
subNumber: number;
|
||||
taxableFlag: boolean;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWForecastRevenueSummary {
|
||||
id: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
margin: number;
|
||||
percentage: number;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWForecast {
|
||||
id: number;
|
||||
forecastItems: CWForecastItem[];
|
||||
productRevenue: CWForecastRevenueSummary;
|
||||
serviceRevenue: CWForecastRevenueSummary;
|
||||
agreementRevenue: CWForecastRevenueSummary;
|
||||
timeRevenue: CWForecastRevenueSummary;
|
||||
expenseRevenue: CWForecastRevenueSummary;
|
||||
forecastRevenueTotals: CWForecastRevenueSummary;
|
||||
inclusiveRevenueTotals: CWForecastRevenueSummary;
|
||||
recurringTotal: number;
|
||||
wonRevenue: CWForecastRevenueSummary;
|
||||
lostRevenue: CWForecastRevenueSummary;
|
||||
openRevenue: CWForecastRevenueSummary;
|
||||
otherRevenue1: CWForecastRevenueSummary;
|
||||
otherRevenue2: CWForecastRevenueSummary;
|
||||
salesTaxRevenue: number;
|
||||
forecastTotalWithTaxes: number;
|
||||
expectedProbability: number;
|
||||
taxCode: CWReference;
|
||||
billingTerms: CWReference;
|
||||
currency: {
|
||||
id: number;
|
||||
symbol: string;
|
||||
currencyCode: string;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
};
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -127,6 +183,18 @@ export interface CWOpportunityNote {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWOpportunityNoteCreate {
|
||||
text: string;
|
||||
type?: { id: number };
|
||||
flagged?: boolean;
|
||||
}
|
||||
|
||||
export interface CWOpportunityNoteUpdate {
|
||||
text?: string;
|
||||
type?: { id: number };
|
||||
flagged?: boolean;
|
||||
}
|
||||
|
||||
export interface CWOpportunityContact {
|
||||
id: number;
|
||||
opportunity: CWReference;
|
||||
|
||||
@@ -96,6 +96,10 @@ export const refreshCatalog = async () => {
|
||||
description: item.description,
|
||||
customerDescription: item.customerDescription,
|
||||
internalNotes: item.notes,
|
||||
category: item.category?.name,
|
||||
categoryCwId: item.category?.id,
|
||||
subcategory: item.subcategory?.name,
|
||||
subcategoryCwId: item.subcategory?.id,
|
||||
manufacturer: item.manufacturer?.name,
|
||||
manufactureCwId: item.manufacturer?.id,
|
||||
partNumber: item.manufacturerPartNumber,
|
||||
@@ -115,6 +119,10 @@ export const refreshCatalog = async () => {
|
||||
description: item.description,
|
||||
customerDescription: item.customerDescription,
|
||||
internalNotes: item.notes,
|
||||
category: item.category?.name,
|
||||
categoryCwId: item.category?.id,
|
||||
subcategory: item.subcategory?.name,
|
||||
subcategoryCwId: item.subcategory?.id,
|
||||
manufacturer: item.manufacturer?.name,
|
||||
manufactureCwId: item.manufacturer?.id,
|
||||
partNumber: item.manufacturerPartNumber,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
|
||||
export interface CWCompanySite {
|
||||
id: number;
|
||||
name: string;
|
||||
addressLine1: string;
|
||||
addressLine2?: string;
|
||||
city: string;
|
||||
stateReference: { id: number; identifier: string; name: string } | null;
|
||||
zip: string;
|
||||
country: { id: number; name: string } | null;
|
||||
phoneNumber: string;
|
||||
faxNumber: string;
|
||||
taxCodeId: number | null;
|
||||
expenseReimbursement: number;
|
||||
primaryAddressFlag: boolean;
|
||||
defaultShippingFlag: boolean;
|
||||
defaultBillingFlag: boolean;
|
||||
defaultMailingFlag: boolean;
|
||||
mobileGuid: string;
|
||||
calendar: { id: number; name: string } | null;
|
||||
timeZone: { id: number; name: string } | null;
|
||||
company: { id: number; identifier: string; name: string };
|
||||
_info: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all sites for a ConnectWise company.
|
||||
*
|
||||
* @param cwCompanyId - The ConnectWise company ID
|
||||
* @returns Array of CW company sites
|
||||
*/
|
||||
export const fetchCompanySites = async (
|
||||
cwCompanyId: number,
|
||||
): Promise<CWCompanySite[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/company/companies/${cwCompanyId}/sites?pageSize=1000`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a single site by CW site ID for a given company.
|
||||
*
|
||||
* @param cwCompanyId - The ConnectWise company ID
|
||||
* @param cwSiteId - The ConnectWise site ID
|
||||
* @returns The CW company site
|
||||
*/
|
||||
export const fetchCompanySite = async (
|
||||
cwCompanyId: number,
|
||||
cwSiteId: number,
|
||||
): Promise<CWCompanySite> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/company/companies/${cwCompanyId}/sites/${cwSiteId}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize a CW site into a clean API-friendly object.
|
||||
*/
|
||||
export const serializeCwSite = (site: CWCompanySite) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
address: {
|
||||
line1: site.addressLine1,
|
||||
line2: site.addressLine2 ?? null,
|
||||
city: site.city,
|
||||
state: site.stateReference?.name ?? null,
|
||||
zip: site.zip,
|
||||
country: site.country?.name ?? "United States",
|
||||
},
|
||||
phoneNumber: site.phoneNumber || null,
|
||||
faxNumber: site.faxNumber || null,
|
||||
primaryAddressFlag: site.primaryAddressFlag,
|
||||
defaultShippingFlag: site.defaultShippingFlag,
|
||||
defaultBillingFlag: site.defaultBillingFlag,
|
||||
defaultMailingFlag: site.defaultMailingFlag,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export { userDefinedFieldsCw } from "./userDefinedFields";
|
||||
export type {
|
||||
CWUserDefinedField,
|
||||
CWUserDefinedFieldOption,
|
||||
CWUserDefinedFieldInfo,
|
||||
} from "./udf.types";
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface CWUserDefinedFieldOption {
|
||||
id: number;
|
||||
optionValue: string;
|
||||
defaultFlag: boolean;
|
||||
inactiveFlag: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface CWUserDefinedFieldInfo {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface CWUserDefinedField {
|
||||
id: number;
|
||||
podId: number;
|
||||
caption: string;
|
||||
sequenceNumber: number;
|
||||
screenId: string;
|
||||
helpText?: string;
|
||||
fieldTypeIdentifier: string;
|
||||
numberDecimals: number;
|
||||
entryTypeIdentifier: string;
|
||||
requiredFlag: boolean;
|
||||
displayOnScreenFlag: boolean;
|
||||
readOnlyFlag: boolean;
|
||||
listViewFlag: boolean;
|
||||
options?: CWUserDefinedFieldOption[];
|
||||
businessUnitIds: number[];
|
||||
locationIds: number[];
|
||||
connectWiseID: string;
|
||||
dateCreated: string;
|
||||
_info: CWUserDefinedFieldInfo;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi, redis } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { CWUserDefinedField } from "./udf.types";
|
||||
|
||||
const REDIS_KEY = "cw:userDefinedFields";
|
||||
|
||||
/** In-memory cache of all CW User Defined Fields, keyed by UDF id */
|
||||
let cache: Collection<number, CWUserDefinedField> = new Collection();
|
||||
|
||||
export const userDefinedFieldsCw = {
|
||||
/**
|
||||
* Get Cache
|
||||
*
|
||||
* Returns the current in-memory Collection of all User Defined Fields.
|
||||
* If the cache is empty, it will attempt to hydrate from Redis first,
|
||||
* then fall back to a live API fetch.
|
||||
*/
|
||||
get: async (): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
if (cache.size > 0) return cache;
|
||||
|
||||
// Try hydrating from Redis
|
||||
const stored = await redis.get(REDIS_KEY);
|
||||
if (stored) {
|
||||
const parsed: CWUserDefinedField[] = JSON.parse(stored);
|
||||
cache = new Collection(parsed.map((udf) => [udf.id, udf]));
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Nothing cached anywhere — do a live fetch
|
||||
return userDefinedFieldsCw.refresh();
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All User Defined Fields
|
||||
*
|
||||
* Fetches all UDFs from the ConnectWise API.
|
||||
* Does NOT update the cache — use `refresh()` for that.
|
||||
*/
|
||||
fetchAll: async (): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
const allItems = new Collection<number, CWUserDefinedField>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const response = await connectWiseApi.get(
|
||||
`/system/userDefinedFields?pageSize=${pageSize}`,
|
||||
);
|
||||
const items: CWUserDefinedField[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh
|
||||
*
|
||||
* Fetches all UDFs from ConnectWise, replaces the in-memory cache
|
||||
* and persists the snapshot to Redis.
|
||||
*/
|
||||
refresh: async (): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
events.emit("cw:udf:refresh:started");
|
||||
|
||||
const allItems = await userDefinedFieldsCw.fetchAll();
|
||||
cache = allItems;
|
||||
|
||||
// Persist to Redis
|
||||
await redis.set(REDIS_KEY, JSON.stringify([...allItems.values()]));
|
||||
|
||||
events.emit("cw:udf:refresh:completed", { count: allItems.size });
|
||||
return cache;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find by ID
|
||||
*
|
||||
* Returns a single UDF by its ConnectWise ID from the cache.
|
||||
*/
|
||||
findById: async (id: number): Promise<CWUserDefinedField | undefined> => {
|
||||
const items = await userDefinedFieldsCw.get();
|
||||
return items.get(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find by Caption
|
||||
*
|
||||
* Returns the first UDF matching the given caption (case-insensitive).
|
||||
*/
|
||||
findByCaption: async (
|
||||
caption: string,
|
||||
): Promise<CWUserDefinedField | undefined> => {
|
||||
const items = await userDefinedFieldsCw.get();
|
||||
const lowerCaption = caption.toLowerCase();
|
||||
return items.find((udf) => udf.caption.toLowerCase() === lowerCaption);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find by Screen ID
|
||||
*
|
||||
* Returns all UDFs associated with a given screenId.
|
||||
*/
|
||||
findByScreenId: async (
|
||||
screenId: string,
|
||||
): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
const items = await userDefinedFieldsCw.get();
|
||||
return items.filter((udf) => udf.screenId === screenId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate
|
||||
*
|
||||
* Clears the in-memory cache and removes the Redis key.
|
||||
*/
|
||||
invalidate: async (): Promise<void> => {
|
||||
cache = new Collection();
|
||||
await redis.del(REDIS_KEY);
|
||||
},
|
||||
};
|
||||
@@ -177,6 +177,18 @@ interface EventTypes {
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
|
||||
// ConnectWise User Defined Fields Events
|
||||
"cw:udf:refresh:started": () => void;
|
||||
"cw:udf:refresh:completed": (data: { count: number }) => void;
|
||||
|
||||
// ConnectWise Members Events
|
||||
"cw:members:refresh:started": () => void;
|
||||
"cw:members:refresh:completed": (data: {
|
||||
totalMembers: number;
|
||||
totalUsers: number;
|
||||
usersUpdated: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const events = new Eventra<EventTypes>();
|
||||
|
||||
Reference in New Issue
Block a user