Fix UserController permission serialization and include current updates

This commit is contained in:
2026-02-27 14:38:22 -06:00
parent 51eb36f4a6
commit b1f6462ac3
50 changed files with 6150 additions and 30 deletions
@@ -0,0 +1,28 @@
import { Collection } from "@discordjs/collection";
import GenericError from "../../../Errors/GenericError";
import { opportunityCw } from "./opportunities";
import { CWOpportunity } from "./opportunity.types";
/**
* Fetch all opportunities from ConnectWise with optional conditions.
*
* @param conditions - Optional CW conditions string for filtering
* @returns A Collection of CW opportunities keyed by their ID
* @throws GenericError if the fetch fails
*/
export const fetchAllOpportunities = async (
conditions?: string,
): Promise<Collection<number, CWOpportunity>> => {
try {
return await opportunityCw.fetchAll(conditions);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error("Error fetching all opportunities:", errBody);
throw new GenericError({
name: "FetchAllOpportunitiesError",
message: "Failed to fetch opportunities from ConnectWise",
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
@@ -0,0 +1,31 @@
import { Collection } from "@discordjs/collection";
import GenericError from "../../../Errors/GenericError";
import { opportunityCw } from "./opportunities";
import { CWOpportunity } from "./opportunity.types";
/**
* Fetch all opportunities for a specific company from ConnectWise.
*
* @param cwCompanyId - The ConnectWise company ID
* @returns A Collection of CW opportunities for the company keyed by their ID
* @throws GenericError if the fetch fails
*/
export const fetchCompanyOpportunities = async (
cwCompanyId: number,
): Promise<Collection<number, CWOpportunity>> => {
try {
return await opportunityCw.fetchByCompany(cwCompanyId);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error(
`Error fetching opportunities for company ${cwCompanyId}:`,
errBody,
);
throw new GenericError({
name: "FetchCompanyOpportunitiesError",
message: `Failed to fetch opportunities for company ${cwCompanyId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
@@ -0,0 +1,30 @@
import GenericError from "../../../Errors/GenericError";
import { opportunityCw } from "./opportunities";
import { CWOpportunity } from "./opportunity.types";
/**
* Fetch a single opportunity by its ConnectWise ID.
*
* @param cwOpportunityId - The ConnectWise opportunity ID
* @returns The full CW opportunity object
* @throws GenericError if the fetch fails
*/
export const fetchOpportunity = async (
cwOpportunityId: number,
): Promise<CWOpportunity> => {
try {
return await opportunityCw.fetch(cwOpportunityId);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error(
`Error fetching opportunity with ID ${cwOpportunityId}:`,
errBody,
);
throw new GenericError({
name: "FetchOpportunityError",
message: `Failed to fetch opportunity ${cwOpportunityId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
@@ -0,0 +1,145 @@
import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWOpportunity,
CWOpportunitySummary,
CWForecastItem,
CWOpportunityNote,
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 Forecasts
*
* Fetches forecast/revenue items for a given opportunity.
*/
fetchForecasts: async (opportunityId: number): Promise<CWForecastItem[]> => {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/forecast`,
);
return response.data;
},
/**
* 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 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;
},
};
@@ -0,0 +1,144 @@
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>;
}
interface CWSiteReference {
id: number;
name: string;
_info?: Record<string, string>;
}
interface CWCustomField {
id: number;
caption: string;
type: string;
entryMethod: string;
numberOfDecimals: number;
value: unknown;
connectWiseId: string;
rowNum: number;
userDefinedFieldRecId: number;
podId: string;
}
export interface CWOpportunity {
id: number;
name: string;
expectedCloseDate: string;
type: CWReference;
stage: CWReference;
status: CWReference;
priority: CWReference;
notes: string;
source: string;
rating: CWReference;
campaign: CWReference;
primarySalesRep: CWMemberReference;
secondarySalesRep: CWMemberReference;
locationId: number;
businessUnitId: number;
company: CWCompanyReference;
contact: CWContactReference;
site: CWSiteReference;
customerPO: string;
pipelineChangeDate: string;
dateBecameLead: string;
closedDate: string;
closedBy: CWMemberReference;
totalSalesTax: number;
shipToCompany: CWCompanyReference;
shipToContact: CWContactReference;
shipToSite: CWSiteReference;
billToCompany: CWCompanyReference;
billToContact: CWContactReference;
billToSite: CWSiteReference;
billingTerms: CWReference;
taxCode: CWReference;
currency: CWReference;
companyLocationId: number;
location: CWReference;
department: CWReference;
closedFlag: boolean;
mobileGuid: string;
customFields: CWCustomField[];
_info: CWOpportunityInfo;
}
export interface CWOpportunityInfo {
lastUpdated: string;
updatedBy: string;
dateEntered: string;
enteredBy: string;
forecasts_href: string;
notes_href: string;
products_href: string;
contacts_href: string;
configurations_href: string;
team_href: string;
documents_href: string;
activities_href: string;
}
export interface CWForecastItem {
id: number;
opportunity: CWReference;
forecastType: string;
forecastMonth: string;
revenue: number;
cost: number;
forecastPercentage: number;
status: CWReference;
includedFlag: boolean;
linkedFlag: boolean;
recurringFlag: boolean;
_info?: Record<string, string>;
}
export interface CWOpportunityNote {
id: number;
opportunity: CWReference;
text: string;
type: CWReference;
flagged: boolean;
enteredBy: string;
mobileGuid: string;
_info?: Record<string, string>;
}
export interface CWOpportunityContact {
id: number;
opportunity: CWReference;
contact: CWContactReference;
company: CWCompanyReference;
role: CWReference;
notes: string;
referralFlag: boolean;
_info?: Record<string, string>;
}
export interface CWOpportunitySummary {
id: number;
_info?: Record<string, string>;
}
@@ -0,0 +1,88 @@
import { CWOpportunity } from "./opportunity.types";
export type ProcessedOpportunity = ReturnType<
typeof processOpportunityResponse
>;
/**
* Processes raw CW opportunity data into a cleaner, normalized shape
* suitable for API responses and internal consumption.
*/
export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
id: opportunity.id,
name: opportunity.name,
expectedCloseDate: opportunity.expectedCloseDate,
closedDate: opportunity.closedDate,
closedFlag: opportunity.closedFlag,
type: opportunity.type
? { id: opportunity.type.id, name: opportunity.type.name }
: null,
stage: opportunity.stage
? { id: opportunity.stage.id, name: opportunity.stage.name }
: null,
status: opportunity.status
? { id: opportunity.status.id, name: opportunity.status.name }
: null,
priority: opportunity.priority
? { id: opportunity.priority.id, name: opportunity.priority.name }
: null,
rating: opportunity.rating
? { id: opportunity.rating.id, name: opportunity.rating.name }
: null,
source: opportunity.source,
notes: opportunity.notes,
customerPO: opportunity.customerPO,
company: opportunity.company
? {
id: opportunity.company.id,
identifier: opportunity.company.identifier,
name: opportunity.company.name,
}
: null,
contact: opportunity.contact
? { id: opportunity.contact.id, name: opportunity.contact.name }
: null,
site: opportunity.site
? { id: opportunity.site.id, name: opportunity.site.name }
: null,
primarySalesRep: opportunity.primarySalesRep
? {
id: opportunity.primarySalesRep.id,
identifier: opportunity.primarySalesRep.identifier,
name: opportunity.primarySalesRep.name,
}
: null,
secondarySalesRep: opportunity.secondarySalesRep
? {
id: opportunity.secondarySalesRep.id,
identifier: opportunity.secondarySalesRep.identifier,
name: opportunity.secondarySalesRep.name,
}
: null,
closedBy: opportunity.closedBy
? {
id: opportunity.closedBy.id,
identifier: opportunity.closedBy.identifier,
name: opportunity.closedBy.name,
}
: null,
campaign: opportunity.campaign
? { id: opportunity.campaign.id, name: opportunity.campaign.name }
: null,
totalSalesTax: opportunity.totalSalesTax,
location: opportunity.location
? { id: opportunity.location.id, name: opportunity.location.name }
: null,
department: opportunity.department
? { id: opportunity.department.id, name: opportunity.department.name }
: null,
pipelineChangeDate: opportunity.pipelineChangeDate,
dateBecameLead: opportunity.dateBecameLead,
info: opportunity._info,
});
/**
* Processes an array of raw CW opportunities.
*/
export const processOpportunitiesResponse = (opportunities: CWOpportunity[]) =>
opportunities.map(processOpportunityResponse);
@@ -0,0 +1,110 @@
import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { opportunityCw } from "./opportunities";
import { OpportunityController } from "../../../controllers/OpportunityController";
/**
* Refresh Opportunities
*
* Syncs local opportunity records with ConnectWise using the same
* stale-check pattern as refreshCatalog:
* 1. Fetch lightweight summaries (id + _info.lastUpdated)
* 2. Compare against local cwLastUpdated timestamps
* 3. Full-fetch only stale/new records
* 4. Upsert stale items, optionally linking to internal Company
*/
export const refreshOpportunities = async () => {
events.emit("cw:opportunities:refresh:check");
// 1. Fetch lightweight summaries from CW
const cwSummaries = await opportunityCw.fetchAllSummaries();
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
const dbItems = await prisma.opportunity.findMany({
select: { cwOpportunityId: true, cwLastUpdated: true },
});
const dbMap = new Map(
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
);
// 3. Determine stale / new IDs
const staleIds: number[] = [];
for (const [cwId, summary] of cwSummaries) {
const cwLastUpdated = summary._info?.lastUpdated
? new Date(summary._info.lastUpdated)
: null;
const dbLastUpdated = dbMap.get(cwId) ?? null;
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
staleIds.push(cwId);
}
}
if (staleIds.length === 0) {
events.emit("cw:opportunities:refresh:skipped", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: 0,
});
return;
}
events.emit("cw:opportunities:refresh:started", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
});
// 4. Full-fetch all opportunities, filter to stale set
const staleIdSet = new Set(staleIds);
const allCwItems = await opportunityCw.fetchAll();
const staleItems = new Map<number, any>();
for (const [id, item] of allCwItems) {
if (staleIdSet.has(id)) {
staleItems.set(id, item);
}
}
// 5. Build a company CW ID → internal ID lookup for linking
const companies = await prisma.company.findMany({
select: { id: true, cw_CompanyId: true },
});
const companyMap = new Map(companies.map((c) => [c.cw_CompanyId, c.id]));
// 6. Upsert stale/new items
const updatedCount = (
await Promise.all(
staleIds.map(async (cwId) => {
const item = staleItems.get(cwId);
if (!item) return null;
const mapped = OpportunityController.mapCwToDb(item);
const companyId = item.company?.id
? (companyMap.get(item.company.id) ?? null)
: null;
return prisma.opportunity.upsert({
where: { cwOpportunityId: cwId },
create: {
cwOpportunityId: cwId,
...mapped,
companyId,
},
update: {
...mapped,
companyId,
},
});
}),
)
).filter(Boolean).length;
events.emit("cw:opportunities:refresh:completed", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
itemsUpdated: updatedCount,
});
};
@@ -91,6 +91,7 @@ export const refreshCatalog = async () => {
where: { cwCatalogId: cwId },
create: {
cwCatalogId: cwId,
identifier: item.identifier,
name: item.description,
description: item.description,
customerDescription: item.customerDescription,
@@ -110,6 +111,7 @@ export const refreshCatalog = async () => {
},
update: {
name: item.description,
identifier: item.identifier,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
+19
View File
@@ -158,6 +158,25 @@ interface EventTypes {
totalItems: number;
updatedCount: number;
}) => void;
// ConnectWise Opportunities Events
"cw:opportunities:refresh:check": () => void;
"cw:opportunities:refresh:started": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
"cw:opportunities:refresh:completed": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
itemsUpdated: number;
}) => void;
"cw:opportunities:refresh:skipped": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
}
export const events = new Eventra<EventTypes>();