Fix UserController permission serialization and include current updates
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user