feat: add CW members, opportunity create/update, and integrator interceptor

This commit is contained in:
2026-03-07 18:15:17 -06:00
parent 0ce1eda606
commit c0a4d4f919
27 changed files with 2504 additions and 16 deletions
@@ -17,20 +17,26 @@ export interface CWMember {
* Fetches every member from ConnectWise using pagination and returns them
* in a Collection keyed by their identifier (e.g. "jroberts").
*
* @param opts.conditions - Optional CW conditions string to filter members
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
*/
export const fetchAllCwMembers = async (): Promise<
Collection<string, CWMember>
> => {
export const fetchAllCwMembers = async (opts?: {
conditions?: string;
}): Promise<Collection<string, CWMember>> => {
const members = new Collection<string, CWMember>();
const pageSize = 1000;
const conditionsParam = opts?.conditions
? `&conditions=${encodeURIComponent(opts.conditions)}`
: "";
const { data: countData } = await connectWiseApi.get("/system/members/count");
const { data: countData } = await connectWiseApi.get(
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
);
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}`,
`/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
);
for (const member of data) {
@@ -0,0 +1,106 @@
import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { fetchAllCwMembers, type CWMember } from "./fetchAllMembers";
import { setMemberCache } from "./memberCache";
import { CwMemberController } from "../../../controllers/CwMemberController";
/**
* Is Regular User
*
* Returns true if the CW member looks like a real person rather than
* a service account (e.g. "labtech", "Admin"). A regular user must
* have a last name and an email address.
*/
const isRegularUser = (member: CWMember): boolean =>
!member.inactiveFlag &&
Boolean(member.lastName?.trim()) &&
Boolean(member.officeEmail?.trim());
/**
* Refresh CW Members
*
* Syncs local CwMember records with ConnectWise using a stale-check
* pattern:
* 1. Fetch all members from CW
* 2. Filter to regular users (active, non-service accounts)
* 3. Compare against local cwLastUpdated timestamps
* 4. Upsert stale/new records
* 5. Also refreshes the in-memory member cache
*/
export const refreshCwMembers = async () => {
events.emit("cw:members:db:refresh:check");
// 1. Fetch all members from CW
const allCwMembers = await fetchAllCwMembers();
// Also refresh the in-memory cache with ALL members (used for name resolution)
await setMemberCache(allCwMembers);
// 2. Filter to regular users only (active, has last name + email)
const cwMembers = allCwMembers.filter(isRegularUser);
// 2. Fetch all DB records with their identifier and cwLastUpdated
const dbItems = await prisma.cwMember.findMany({
select: { cwMemberId: true, cwLastUpdated: true },
});
const dbMap = new Map(
dbItems.map((item) => [item.cwMemberId, item.cwLastUpdated]),
);
// 3. Determine stale / new IDs
const staleIds: number[] = [];
for (const [, member] of cwMembers) {
const cwLastUpdated = member._info?.lastUpdated
? new Date(member._info.lastUpdated)
: null;
const dbLastUpdated = dbMap.get(member.id) ?? null;
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
staleIds.push(member.id);
}
}
if (staleIds.length === 0) {
events.emit("cw:members:db:refresh:skipped", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: 0,
});
return;
}
events.emit("cw:members:db:refresh:started", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
});
// 4. Upsert stale/new items
const staleIdSet = new Set(staleIds);
const updatedCount = (
await Promise.all(
[...cwMembers.values()]
.filter((m) => staleIdSet.has(m.id))
.map(async (member) => {
const mapped = CwMemberController.mapCwToDb(member);
return prisma.cwMember.upsert({
where: { cwMemberId: member.id },
create: {
cwMemberId: member.id,
...mapped,
},
update: mapped,
});
}),
)
).filter(Boolean).length;
events.emit("cw:members:db:refresh:completed", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
itemsUpdated: updatedCount,
});
};
@@ -2,6 +2,7 @@ import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWOpportunity,
CWOpportunityCreate,
CWOpportunitySummary,
CWForecast,
CWForecastItem,
@@ -12,6 +13,7 @@ import {
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
CWOpportunityContact,
CWOpportunityUpdate,
} from "./opportunity.types";
export const opportunityCw = {
@@ -100,6 +102,45 @@ export const opportunityCw = {
return response.data;
},
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise via POST.
* Strips null/undefined values from the payload — CW rejects
* null reference objects on create; omitting them lets CW apply
* its own defaults.
*/
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
const cleaned = Object.fromEntries(
Object.entries(data).filter(([, v]) => v != null),
);
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
return response.data;
},
/**
* Update Opportunity
*
* Applies a JSON Patch update to an opportunity record in ConnectWise.
* Each key in `data` produces a replace operation.
*/
update: async (
opportunityId: number,
data: CWOpportunityUpdate,
): Promise<CWOpportunity> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/sales/opportunities/${opportunityId}`,
operations,
);
return response.data;
},
/**
* Fetch Opportunities by Company
*
@@ -263,6 +263,48 @@ export interface CWProcurementProduct {
_info?: Record<string, string>;
}
export interface CWOpportunityUpdate {
name?: string;
notes?: string;
rating?: { id: number };
type?: { id: number };
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
campaign?: { id: number };
primarySalesRep?: { id: number };
secondarySalesRep?: { id: number } | null;
company?: { id: number };
contact?: { id: number } | null;
site?: { id: number } | null;
expectedCloseDate?: string;
customerPO?: string | null;
source?: string | null;
locationId?: number;
businessUnitId?: number;
}
export interface CWOpportunityCreate {
name: string;
expectedCloseDate: string;
primarySalesRep: { id: number };
company: { id: number };
contact: { id: number };
type?: { id: number };
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
campaign?: { id: number };
secondarySalesRep?: { id: number } | null;
site?: { id: number } | null;
notes?: string;
rating?: { id: number };
source?: string | null;
customerPO?: string | null;
locationId?: number;
businessUnitId?: number;
}
export interface CWOpportunitySummary {
id: number;
_info?: Record<string, string>;
+19
View File
@@ -205,6 +205,25 @@ interface EventTypes {
totalUsers: number;
usersUpdated: number;
}) => void;
// ConnectWise Members DB Sync Events
"cw:members:db:refresh:check": () => void;
"cw:members:db:refresh:started": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
"cw:members:db:refresh:completed": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
itemsUpdated: number;
}) => void;
"cw:members:db:refresh:skipped": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
}
export const events = new Eventra<EventTypes>();