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,252 @@
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivityCustomField,
|
||||
CWPatchOperation,
|
||||
CWCreateActivity,
|
||||
} from "../modules/cw-utils/activities/activity.types";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { fetchActivity } from "../modules/cw-utils/activities/fetchActivity";
|
||||
|
||||
/**
|
||||
* Activity Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Activity entity.
|
||||
* Activities are not persisted locally — all data is sourced directly
|
||||
* from the ConnectWise API.
|
||||
*/
|
||||
export class ActivityController {
|
||||
public readonly cwActivityId: number;
|
||||
public name: string;
|
||||
public notes: string | null;
|
||||
|
||||
public typeName: string | null;
|
||||
public typeCwId: number | null;
|
||||
public statusName: string | null;
|
||||
public statusCwId: number | null;
|
||||
|
||||
public companyCwId: number | null;
|
||||
public companyName: string | null;
|
||||
public companyIdentifier: string | null;
|
||||
public contactCwId: number | null;
|
||||
public contactName: string | null;
|
||||
|
||||
public phoneNumber: string | null;
|
||||
public email: string | null;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
public ticketCwId: number | null;
|
||||
public ticketName: string | null;
|
||||
public agreementCwId: number | null;
|
||||
public agreementName: string | null;
|
||||
public campaignCwId: number | null;
|
||||
public campaignName: string | null;
|
||||
|
||||
public assignToCwId: number | null;
|
||||
public assignToName: string | null;
|
||||
public assignToIdentifier: string | null;
|
||||
|
||||
public scheduleStatusCwId: number | null;
|
||||
public scheduleStatusName: string | null;
|
||||
public reminderCwId: number | null;
|
||||
public reminderName: string | null;
|
||||
public whereCwId: number | null;
|
||||
public whereName: string | null;
|
||||
|
||||
public dateStart: Date | null;
|
||||
public dateEnd: Date | null;
|
||||
public notifyFlag: boolean;
|
||||
|
||||
public currencyCwId: number | null;
|
||||
public currencyName: string | null;
|
||||
|
||||
public mobileGuid: string | null;
|
||||
public customFields: CWActivityCustomField[];
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwDateEntered: Date | null;
|
||||
public cwEnteredBy: string | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
constructor(data: CWActivity) {
|
||||
this.cwActivityId = data.id;
|
||||
this.name = data.name;
|
||||
this.notes = data.notes ?? null;
|
||||
|
||||
this.typeName = data.type?.name ?? null;
|
||||
this.typeCwId = data.type?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
|
||||
this.companyCwId = data.company?.id ?? null;
|
||||
this.companyName = data.company?.name ?? null;
|
||||
this.companyIdentifier = data.company?.identifier ?? null;
|
||||
this.contactCwId = data.contact?.id ?? null;
|
||||
this.contactName = data.contact?.name ?? null;
|
||||
|
||||
this.phoneNumber = data.phoneNumber ?? null;
|
||||
this.email = data.email ?? null;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
this.ticketCwId = data.ticket?.id ?? null;
|
||||
this.ticketName = data.ticket?.name ?? null;
|
||||
this.agreementCwId = data.agreement?.id ?? null;
|
||||
this.agreementName = data.agreement?.name ?? null;
|
||||
this.campaignCwId = data.campaign?.id ?? null;
|
||||
this.campaignName = data.campaign?.name ?? null;
|
||||
|
||||
this.assignToCwId = data.assignTo?.id ?? null;
|
||||
this.assignToName = data.assignTo?.name ?? null;
|
||||
this.assignToIdentifier = data.assignTo?.identifier ?? null;
|
||||
|
||||
this.scheduleStatusCwId = data.scheduleStatus?.id ?? null;
|
||||
this.scheduleStatusName = data.scheduleStatus?.name ?? null;
|
||||
this.reminderCwId = data.reminder?.id ?? null;
|
||||
this.reminderName = data.reminder?.name ?? null;
|
||||
this.whereCwId = data.where?.id ?? null;
|
||||
this.whereName = data.where?.name ?? null;
|
||||
|
||||
this.dateStart = data.dateStart ? new Date(data.dateStart) : null;
|
||||
this.dateEnd = data.dateEnd ? new Date(data.dateEnd) : null;
|
||||
this.notifyFlag = data.notifyFlag ?? false;
|
||||
|
||||
this.currencyCwId = data.currency?.id ?? null;
|
||||
this.currencyName = data.currency?.name ?? null;
|
||||
|
||||
this.mobileGuid = data.mobileGuid ?? null;
|
||||
this.customFields = data.customFields ?? [];
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwDateEntered = data._info?.dateEntered
|
||||
? new Date(data._info.dateEntered)
|
||||
: null;
|
||||
this.cwEnteredBy = data._info?.enteredBy ?? null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from ConnectWise
|
||||
*
|
||||
* Fetches the latest activity data from CW and returns
|
||||
* a new ActivityController instance with updated state.
|
||||
*/
|
||||
public async refreshFromCW(): Promise<ActivityController> {
|
||||
const cwData = await fetchActivity(this.cwActivityId);
|
||||
return new ActivityController(cwData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
* Returns the raw ConnectWise activity object.
|
||||
*/
|
||||
public async fetchCwData(): Promise<CWActivity> {
|
||||
return fetchActivity(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update in ConnectWise
|
||||
*
|
||||
* Applies JSON Patch operations to this activity in ConnectWise
|
||||
* and returns a new controller with the updated data.
|
||||
*/
|
||||
public async update(
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<ActivityController> {
|
||||
const updated = await activityCw.update(this.cwActivityId, operations);
|
||||
return new ActivityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from ConnectWise
|
||||
*
|
||||
* Deletes this activity in ConnectWise.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
await activityCw.delete(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Activity (static factory)
|
||||
*
|
||||
* Creates a new activity in ConnectWise and returns a controller instance.
|
||||
*/
|
||||
public static async create(
|
||||
data: CWCreateActivity,
|
||||
): Promise<ActivityController> {
|
||||
const created = await activityCw.create(data);
|
||||
return new ActivityController(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the activity into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
cwActivityId: this.cwActivityId,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
company: this.companyCwId
|
||||
? {
|
||||
id: this.companyCwId,
|
||||
identifier: this.companyIdentifier,
|
||||
name: this.companyName,
|
||||
}
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
phoneNumber: this.phoneNumber,
|
||||
email: this.email,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
ticket: this.ticketCwId
|
||||
? { id: this.ticketCwId, name: this.ticketName }
|
||||
: null,
|
||||
agreement: this.agreementCwId
|
||||
? { id: this.agreementCwId, name: this.agreementName }
|
||||
: null,
|
||||
campaign: this.campaignCwId
|
||||
? { id: this.campaignCwId, name: this.campaignName }
|
||||
: null,
|
||||
assignTo: this.assignToCwId
|
||||
? {
|
||||
id: this.assignToCwId,
|
||||
identifier: this.assignToIdentifier,
|
||||
name: this.assignToName,
|
||||
}
|
||||
: null,
|
||||
scheduleStatus: this.scheduleStatusCwId
|
||||
? { id: this.scheduleStatusCwId, name: this.scheduleStatusName }
|
||||
: null,
|
||||
reminder: this.reminderCwId
|
||||
? { id: this.reminderCwId, name: this.reminderName }
|
||||
: null,
|
||||
where: this.whereCwId
|
||||
? { id: this.whereCwId, name: this.whereName }
|
||||
: null,
|
||||
dateStart: this.dateStart,
|
||||
dateEnd: this.dateEnd,
|
||||
notifyFlag: this.notifyFlag,
|
||||
currency: this.currencyCwId
|
||||
? { id: this.currencyCwId, name: this.currencyName }
|
||||
: null,
|
||||
mobileGuid: this.mobileGuid,
|
||||
customFields: this.customFields,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwDateEntered: this.cwDateEntered,
|
||||
cwEnteredBy: this.cwEnteredBy,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ export class CatalogItemController {
|
||||
public readonly cwCatalogId: number;
|
||||
public readonly identifier: string | null;
|
||||
|
||||
public category: string | null;
|
||||
public categoryCwId: number | null;
|
||||
public subcategory: string | null;
|
||||
public subcategoryCwId: number | null;
|
||||
|
||||
public manufacturer: string | null;
|
||||
public manufactureCwId: number | null;
|
||||
public partNumber: string | null;
|
||||
@@ -55,6 +60,10 @@ export class CatalogItemController {
|
||||
this.internalNotes = itemData.internalNotes;
|
||||
this.cwCatalogId = itemData.cwCatalogId;
|
||||
this.identifier = itemData.identifier;
|
||||
this.category = itemData.category;
|
||||
this.categoryCwId = itemData.categoryCwId;
|
||||
this.subcategory = itemData.subcategory;
|
||||
this.subcategoryCwId = itemData.subcategoryCwId;
|
||||
this.manufacturer = itemData.manufacturer;
|
||||
this.manufactureCwId = itemData.manufactureCwId;
|
||||
this.partNumber = itemData.partNumber;
|
||||
@@ -196,6 +205,10 @@ export class CatalogItemController {
|
||||
description: this.description,
|
||||
customerDescription: this.customerDescription,
|
||||
internalNotes: this.internalNotes,
|
||||
category: this.category,
|
||||
categoryCwId: this.categoryCwId,
|
||||
subcategory: this.subcategory,
|
||||
subcategoryCwId: this.subcategoryCwId,
|
||||
manufacturer: this.manufacturer,
|
||||
manufactureCwId: this.manufactureCwId,
|
||||
partNumber: this.partNumber,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Company } from "../../generated/prisma/client";
|
||||
import { connectWiseApi } from "../constants";
|
||||
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
||||
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
||||
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
|
||||
import {
|
||||
fetchCompanySites,
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
|
||||
|
||||
/**
|
||||
@@ -16,7 +22,7 @@ export class CompanyController {
|
||||
public name: string;
|
||||
public readonly cw_Identifier: string;
|
||||
public readonly cw_CompanyId: number;
|
||||
public readonly cw_Data?: {
|
||||
public cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact | null;
|
||||
allContacts: Contact[];
|
||||
@@ -30,6 +36,38 @@ export class CompanyController {
|
||||
this.cw_Data = cwData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate CW Data
|
||||
*
|
||||
* Fetches and populates the full ConnectWise company data
|
||||
* (company, default contact, all contacts) if not already loaded.
|
||||
*
|
||||
* @returns {ThisType}
|
||||
*/
|
||||
public async hydrateCwData() {
|
||||
if (this.cw_Data) return this;
|
||||
|
||||
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
|
||||
if (!cwCompany) return this;
|
||||
|
||||
const contactHref = cwCompany.defaultContact?._info?.contact_href;
|
||||
const defaultContactData = contactHref
|
||||
? await connectWiseApi.get(contactHref)
|
||||
: undefined;
|
||||
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${cwCompany._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
this.cw_Data = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData?.data ?? null,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Internal Company Data from ConnectWise
|
||||
*
|
||||
@@ -71,6 +109,30 @@ export class CompanyController {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Sites
|
||||
*
|
||||
* Retrieves all sites for this company from ConnectWise
|
||||
* and returns them as serialized site objects.
|
||||
*/
|
||||
public async fetchSites() {
|
||||
const sites = await fetchCompanySites(this.cw_CompanyId);
|
||||
return sites.map(serializeCwSite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Site by ID
|
||||
*
|
||||
* Retrieves a single site by its ConnectWise site ID
|
||||
* and returns a serialized site object.
|
||||
*
|
||||
* @param cwSiteId - The ConnectWise site ID
|
||||
*/
|
||||
public async fetchSite(cwSiteId: number) {
|
||||
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
|
||||
return serializeCwSite(site);
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeAddress: boolean;
|
||||
includePrimaryContact: boolean;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
|
||||
/**
|
||||
* Forecast Product Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Forecast Item (product/
|
||||
* revenue line item on an opportunity). Forecast products are not persisted
|
||||
* locally — all data is sourced directly from the ConnectWise API.
|
||||
*/
|
||||
export class ForecastProductController {
|
||||
public readonly cwForecastId: number;
|
||||
public forecastDescription: string;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
|
||||
public quantity: number;
|
||||
|
||||
public statusCwId: number | null;
|
||||
public statusName: string | null;
|
||||
|
||||
public catalogItemCwId: number | null;
|
||||
public catalogItemIdentifier: string | null;
|
||||
|
||||
public productDescription: string;
|
||||
public productClass: string;
|
||||
public forecastType: string;
|
||||
|
||||
public revenue: number;
|
||||
public cost: number;
|
||||
public margin: number;
|
||||
public percentage: number;
|
||||
|
||||
public includeFlag: boolean;
|
||||
public linkFlag: boolean;
|
||||
public recurringFlag: boolean;
|
||||
public taxableFlag: boolean;
|
||||
|
||||
public recurringRevenue: number;
|
||||
public recurringCost: number;
|
||||
public cycles: number;
|
||||
|
||||
public sequenceNumber: number;
|
||||
public subNumber: number;
|
||||
public quoteWerksQuantity: number;
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
// Cancellation data (from procurement products endpoint)
|
||||
public cancelledFlag: boolean;
|
||||
public quantityCancelled: number;
|
||||
public cancelledReason: string | null;
|
||||
public cancelledBy: number | null;
|
||||
public cancelledDate: Date | null;
|
||||
|
||||
// Internal inventory data (from local CatalogItem database)
|
||||
public onHand: number | null;
|
||||
public inStock: boolean | null;
|
||||
|
||||
constructor(data: CWForecastItem) {
|
||||
this.cwForecastId = data.id;
|
||||
this.forecastDescription = data.forecastDescription;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
|
||||
this.quantity = data.quantity;
|
||||
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
|
||||
this.catalogItemCwId = data.catalogItem?.id ?? null;
|
||||
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
|
||||
|
||||
this.productDescription = data.productDescription;
|
||||
this.productClass = data.productClass;
|
||||
this.forecastType = data.forecastType;
|
||||
|
||||
this.revenue = data.revenue;
|
||||
this.cost = data.cost;
|
||||
this.margin = data.margin;
|
||||
this.percentage = data.percentage;
|
||||
|
||||
this.includeFlag = data.includeFlag ?? false;
|
||||
this.linkFlag = data.linkFlag ?? false;
|
||||
this.recurringFlag = data.recurringFlag ?? false;
|
||||
this.taxableFlag = data.taxableFlag ?? false;
|
||||
|
||||
this.recurringRevenue = data.recurringRevenue ?? 0;
|
||||
this.recurringCost = data.recurringCost ?? 0;
|
||||
this.cycles = data.cycles ?? 0;
|
||||
|
||||
this.sequenceNumber = data.sequenceNumber ?? 0;
|
||||
this.subNumber = data.subNumber ?? 0;
|
||||
this.quoteWerksQuantity = data.quoteWerksQuantity ?? 0;
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
|
||||
// Cancellation defaults — enriched later via applyCancellationData()
|
||||
this.cancelledFlag = false;
|
||||
this.quantityCancelled = 0;
|
||||
this.cancelledReason = null;
|
||||
this.cancelledBy = null;
|
||||
this.cancelledDate = null;
|
||||
|
||||
// Inventory defaults — enriched later via applyInventoryData()
|
||||
this.onHand = null;
|
||||
this.inStock = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Cancellation Data
|
||||
*
|
||||
* Enriches this forecast product with cancellation data from the
|
||||
* procurement products endpoint.
|
||||
*/
|
||||
public applyCancellationData(data: {
|
||||
cancelledFlag?: boolean;
|
||||
quantityCancelled?: number;
|
||||
cancelledReason?: string;
|
||||
cancelledBy?: number;
|
||||
cancelledDate?: string;
|
||||
}): void {
|
||||
this.cancelledFlag = data.cancelledFlag ?? false;
|
||||
this.quantityCancelled = data.quantityCancelled ?? 0;
|
||||
this.cancelledReason = data.cancelledReason ?? null;
|
||||
this.cancelledBy = data.cancelledBy ?? null;
|
||||
this.cancelledDate = data.cancelledDate
|
||||
? new Date(data.cancelledDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Inventory Data
|
||||
*
|
||||
* Enriches this forecast product with internal inventory data from
|
||||
* the local CatalogItem database.
|
||||
*/
|
||||
public applyInventoryData(data: { onHand: number }): void {
|
||||
this.onHand = data.onHand;
|
||||
this.inStock = data.onHand > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profit
|
||||
*
|
||||
* Returns the calculated profit (revenue - cost).
|
||||
*/
|
||||
public get profit(): number {
|
||||
return this.revenue - this.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled
|
||||
*
|
||||
* Returns true if the forecast item has been cancelled (fully or partially).
|
||||
*/
|
||||
public get cancelled(): boolean {
|
||||
return this.cancelledFlag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation Type
|
||||
*
|
||||
* Returns the type of cancellation:
|
||||
* - `"full"` — all units have been cancelled (`quantityCancelled >= quantity`)
|
||||
* - `"partial"` — some units cancelled but not all
|
||||
* - `null` — not cancelled
|
||||
*/
|
||||
public get cancellationType(): "full" | "partial" | null {
|
||||
if (!this.cancelledFlag || this.quantityCancelled <= 0) return null;
|
||||
return this.quantityCancelled >= this.quantity ? "full" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the forecast product into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.cwForecastId,
|
||||
forecastDescription: this.forecastDescription,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
quantity: this.quantity,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
cancelled: this.cancelled,
|
||||
cancellationType: this.cancellationType,
|
||||
quantityCancelled: this.quantityCancelled,
|
||||
cancelledReason: this.cancelledReason,
|
||||
cancelledDate: this.cancelledDate,
|
||||
catalogItem: this.catalogItemCwId
|
||||
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
|
||||
: null,
|
||||
productDescription: this.productDescription,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
percentage: this.percentage,
|
||||
includeFlag: this.includeFlag,
|
||||
linkFlag: this.linkFlag,
|
||||
recurringFlag: this.recurringFlag,
|
||||
taxableFlag: this.taxableFlag,
|
||||
recurringRevenue: this.recurringRevenue,
|
||||
recurringCost: this.recurringCost,
|
||||
cycles: this.cycles,
|
||||
sequenceNumber: this.sequenceNumber,
|
||||
subNumber: this.subNumber,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
onHand: this.onHand,
|
||||
inStock: this.inStock,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
import { Opportunity } from "../../generated/prisma/client";
|
||||
import { Company, Opportunity } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { CompanyController } from "./CompanyController";
|
||||
import { ActivityController } from "./ActivityController";
|
||||
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
|
||||
import { CWOpportunity } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import {
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import {
|
||||
CWCustomField,
|
||||
CWOpportunity,
|
||||
CWOpportunityNote,
|
||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { resolveMember } from "../modules/cw-utils/members/memberCache";
|
||||
import { ForecastProductController } from "./ForecastProductController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* Opportunity Controller
|
||||
@@ -66,7 +81,19 @@ export class OpportunityController {
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(data: Opportunity) {
|
||||
private _company: CompanyController | null = null;
|
||||
private _siteData: ReturnType<typeof serializeCwSite> | null = null;
|
||||
private _customFields: CWCustomField[] | null = null;
|
||||
private _activities: ActivityController[] | null = null;
|
||||
|
||||
constructor(
|
||||
data: Opportunity & { company?: Company | null },
|
||||
opts?: {
|
||||
company?: CompanyController;
|
||||
customFields?: CWCustomField[];
|
||||
activities?: ActivityController[];
|
||||
},
|
||||
) {
|
||||
this.id = data.id;
|
||||
this.cwOpportunityId = data.cwOpportunityId;
|
||||
this.name = data.name;
|
||||
@@ -121,6 +148,39 @@ export class OpportunityController {
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
|
||||
this._company =
|
||||
opts?.company ??
|
||||
(data.company ? new CompanyController(data.company) : null);
|
||||
|
||||
this._customFields = opts?.customFields ?? null;
|
||||
this._activities = opts?.activities ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company
|
||||
*
|
||||
* Lazily loads the associated CompanyController from the database
|
||||
* if not already loaded via the Prisma include.
|
||||
*
|
||||
* @returns {Promise<CompanyController | null>}
|
||||
*/
|
||||
public async fetchCompany(): Promise<CompanyController | null> {
|
||||
if (this._company) {
|
||||
await this._company.hydrateCwData();
|
||||
return this._company;
|
||||
}
|
||||
if (!this.companyId) return null;
|
||||
|
||||
const companyData = await prisma.company.findUnique({
|
||||
where: { id: this.companyId },
|
||||
});
|
||||
|
||||
if (!companyData) return null;
|
||||
|
||||
this._company = new CompanyController(companyData);
|
||||
await this._company.hydrateCwData();
|
||||
return this._company;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +196,7 @@ export class OpportunityController {
|
||||
const updated = await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: mapped,
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
return new OpportunityController(updated);
|
||||
@@ -216,6 +277,403 @@ export class OpportunityController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Site
|
||||
*
|
||||
* Fetches the full site details (address, phone, flags) from ConnectWise
|
||||
* for the site associated with this opportunity.
|
||||
* Requires both companyCwId and siteCwId to be set.
|
||||
*
|
||||
* @returns Serialized site object or null
|
||||
*/
|
||||
public async fetchSite() {
|
||||
if (this._siteData) return this._siteData;
|
||||
if (!this.companyCwId || !this.siteCwId) return null;
|
||||
|
||||
const cwSite = await fetchCompanySite(this.companyCwId, this.siteCwId);
|
||||
this._siteData = serializeCwSite(cwSite);
|
||||
return this._siteData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Contacts
|
||||
*
|
||||
* Fetches contacts associated with this opportunity from ConnectWise
|
||||
* and returns a serialized array.
|
||||
*/
|
||||
public async fetchContacts() {
|
||||
const contacts = await opportunityCw.fetchContacts(this.cwOpportunityId);
|
||||
|
||||
return contacts.map((ct) => ({
|
||||
id: ct.id,
|
||||
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
||||
company: ct.company
|
||||
? {
|
||||
id: ct.company.id,
|
||||
identifier: ct.company.identifier,
|
||||
name: ct.company.name,
|
||||
}
|
||||
: null,
|
||||
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
||||
notes: ct.notes,
|
||||
referralFlag: ct.referralFlag,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Notes
|
||||
*
|
||||
* Fetches notes associated with this opportunity from ConnectWise
|
||||
* and returns a serialized array.
|
||||
*/
|
||||
public async fetchNotes() {
|
||||
const notes = await opportunityCw.fetchNotes(this.cwOpportunityId);
|
||||
|
||||
return Promise.all(
|
||||
notes.map(async (n) => ({
|
||||
id: n.id,
|
||||
text: n.text,
|
||||
type: n.type ? { id: n.type.id, name: n.type.name } : null,
|
||||
flagged: n.flagged,
|
||||
dateEntered: n._info?.lastUpdated
|
||||
? new Date(n._info.lastUpdated)
|
||||
: null,
|
||||
enteredBy: await resolveMember(n.enteredBy),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Single Note
|
||||
*
|
||||
* Fetches a single note by its ID from ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID
|
||||
*/
|
||||
public async fetchNote(noteId: number) {
|
||||
const note = await opportunityCw.fetchNote(this.cwOpportunityId, noteId);
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
type: note.type ? { id: note.type.id, name: note.type.name } : null,
|
||||
flagged: note.flagged,
|
||||
enteredBy: await resolveMember(note.enteredBy),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Activities
|
||||
*
|
||||
* Fetches activities associated with this opportunity from ConnectWise
|
||||
* and returns an array of ActivityController instances.
|
||||
* Results are cached after the first call.
|
||||
*/
|
||||
public async fetchActivities(): Promise<ActivityController[]> {
|
||||
if (this._activities) return this._activities;
|
||||
|
||||
const collection = await activityCw.fetchByOpportunity(
|
||||
this.cwOpportunityId,
|
||||
);
|
||||
this._activities = collection.map((item) => new ActivityController(item));
|
||||
return this._activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Products
|
||||
*
|
||||
* Fetches products (forecast/revenue items) for this opportunity from
|
||||
* ConnectWise and returns ForecastProductController instances.
|
||||
*/
|
||||
public async fetchProducts(): Promise<ForecastProductController[]> {
|
||||
const [forecast, procProducts] = await Promise.all([
|
||||
opportunityCw.fetchProducts(this.cwOpportunityId),
|
||||
opportunityCw.fetchProcurementProducts(this.cwOpportunityId),
|
||||
]);
|
||||
|
||||
// Build a map of forecastDetailId → procurement product cancellation data
|
||||
const cancellationMap = new Map<number, Record<string, unknown>>();
|
||||
for (const pp of procProducts) {
|
||||
const forecastDetailId = pp.forecastDetailId as number | undefined;
|
||||
if (forecastDetailId) {
|
||||
cancellationMap.set(forecastDetailId, pp);
|
||||
}
|
||||
}
|
||||
|
||||
const controllers = (forecast.forecastItems ?? [])
|
||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
|
||||
.map((item) => {
|
||||
const ctrl = new ForecastProductController(item);
|
||||
const procData = cancellationMap.get(item.id);
|
||||
if (procData) {
|
||||
ctrl.applyCancellationData(procData as any);
|
||||
}
|
||||
return ctrl;
|
||||
});
|
||||
|
||||
// Enrich with internal inventory data from local CatalogItem DB
|
||||
const catalogCwIds = controllers
|
||||
.map((c) => c.catalogItemCwId)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
if (catalogCwIds.length > 0) {
|
||||
const catalogItems = await prisma.catalogItem.findMany({
|
||||
where: { cwCatalogId: { in: catalogCwIds } },
|
||||
select: { cwCatalogId: true, onHand: true },
|
||||
});
|
||||
const inventoryMap = new Map(
|
||||
catalogItems.map((ci) => [ci.cwCatalogId, ci]),
|
||||
);
|
||||
for (const ctrl of controllers) {
|
||||
const inv = ctrl.catalogItemCwId
|
||||
? inventoryMap.get(ctrl.catalogItemCwId)
|
||||
: undefined;
|
||||
if (inv) ctrl.applyInventoryData(inv);
|
||||
}
|
||||
}
|
||||
|
||||
return controllers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Opportunity Activity / Workflow Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set Internal Review
|
||||
*
|
||||
* The quote is ready to be reviewed before it is ready to be sent.
|
||||
*/
|
||||
public async setInternalReview(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Internal Approved
|
||||
*
|
||||
* The quote has been approved and is ready to be sent out.
|
||||
*/
|
||||
public async setInternalApproved(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Quote Sent
|
||||
*
|
||||
* The quote has been sent to the customer.
|
||||
*/
|
||||
public async setQuoteSent(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Quote Confirmed
|
||||
*
|
||||
* The quote has been received by the customer.
|
||||
*/
|
||||
public async setQuoteConfirmed(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Revision Needed
|
||||
*
|
||||
* The quote needs to be revised and is set to stage revision.
|
||||
*/
|
||||
public async setRevisionNeeded(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Finalized
|
||||
*
|
||||
* Locks any non-admins from modifying the quote, indicating
|
||||
* this is the final iteration of the quote.
|
||||
*/
|
||||
public async setFinalized(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert
|
||||
*
|
||||
* Converts the quote to a ticket and updates all necessary fields.
|
||||
*/
|
||||
public async convert(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Time
|
||||
*
|
||||
* Adds time to an activity on this opportunity.
|
||||
*
|
||||
* @param activityId - The CW activity ID to add time to
|
||||
* @param user - The user identifier adding time
|
||||
*/
|
||||
public async addTime(activityId: number, user: string): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Product
|
||||
*
|
||||
* Updates an existing product/line item on this opportunity via PATCH.
|
||||
*
|
||||
* @param forecastItemId - The CW forecast item ID to update
|
||||
* @param data - Key/value pairs to patch
|
||||
*/
|
||||
public async updateProduct(
|
||||
forecastItemId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ForecastProductController> {
|
||||
try {
|
||||
const updated = await opportunityCw.updateProduct(
|
||||
this.cwOpportunityId,
|
||||
forecastItemId,
|
||||
data,
|
||||
);
|
||||
return new ForecastProductController(updated);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[updateProduct] Failed to patch forecast item ${forecastItemId} on opportunity ${this.cwOpportunityId}`,
|
||||
JSON.stringify(
|
||||
{
|
||||
data,
|
||||
status: err?.response?.status,
|
||||
statusText: err?.response?.statusText,
|
||||
responseData: err?.response?.data,
|
||||
message: err?.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resequence Products
|
||||
*
|
||||
* Updates the sequenceNumber on each forecast item to match the
|
||||
* order provided. Fetches the current items first so the PUT
|
||||
* includes all required fields. Expects an array of forecast item
|
||||
* IDs in the desired order.
|
||||
*
|
||||
* @param orderedIds - Forecast item IDs in the desired sequence order
|
||||
*/
|
||||
public async resequenceProducts(
|
||||
orderedIds: number[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
// Fetch existing items so we can include required fields in the PUT
|
||||
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
|
||||
const itemMap = new Map(
|
||||
(forecast.forecastItems ?? []).map((fi) => [fi.id, fi]),
|
||||
);
|
||||
|
||||
// Validate all IDs exist before making any updates
|
||||
for (const id of orderedIds) {
|
||||
if (!itemMap.has(id)) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${id} not found on opportunity ${this.cwOpportunityId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run updates in reverse order to CW
|
||||
const results: ForecastProductController[] = new Array(orderedIds.length);
|
||||
for (let index = orderedIds.length - 1; index >= 0; index--) {
|
||||
const id = orderedIds[index]!;
|
||||
const existing = itemMap.get(id)!;
|
||||
const raw = JSON.parse(JSON.stringify(existing)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Strip read-only _info fields at top level and nested sub-objects
|
||||
delete raw._info;
|
||||
for (const key of ["opportunity", "status", "catalogItem"]) {
|
||||
if (raw[key] && typeof raw[key] === "object") {
|
||||
delete (raw[key] as Record<string, unknown>)._info;
|
||||
}
|
||||
}
|
||||
|
||||
const newSeq = index + 1;
|
||||
|
||||
const result = await this.updateProduct(id, {
|
||||
...raw,
|
||||
sequenceNumber: newSeq,
|
||||
});
|
||||
|
||||
results[index] = result;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Product
|
||||
*
|
||||
* Adds a new product/line item to this opportunity.
|
||||
*/
|
||||
public async addProduct(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Note
|
||||
*
|
||||
* Creates a new note on this opportunity in ConnectWise.
|
||||
*
|
||||
* @param note - The note text to add
|
||||
* @param user - The user identifier adding the note
|
||||
* @param opts - Optional flags
|
||||
*/
|
||||
public async addNote(
|
||||
note: string,
|
||||
user: string,
|
||||
opts?: { flagged?: boolean },
|
||||
): Promise<CWOpportunityNote> {
|
||||
const created = await opportunityCw.createNote(this.cwOpportunityId, {
|
||||
text: note,
|
||||
flagged: opts?.flagged ?? false,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Note
|
||||
*
|
||||
* Updates an existing note on this opportunity in ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID to update
|
||||
* @param data - The fields to update
|
||||
*/
|
||||
public async updateNote(
|
||||
noteId: number,
|
||||
data: { text?: string; flagged?: boolean },
|
||||
): Promise<CWOpportunityNote> {
|
||||
const updated = await opportunityCw.updateNote(
|
||||
this.cwOpportunityId,
|
||||
noteId,
|
||||
data,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Note
|
||||
*
|
||||
* Deletes a note from this opportunity in ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID to delete
|
||||
*/
|
||||
public async deleteNote(noteId: number): Promise<void> {
|
||||
await opportunityCw.deleteNote(this.cwOpportunityId, noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
@@ -258,13 +716,23 @@ export class OpportunityController {
|
||||
name: this.secondarySalesRepName,
|
||||
}
|
||||
: null,
|
||||
company: this.companyCwId
|
||||
? { id: this.companyCwId, name: this.companyName }
|
||||
: null,
|
||||
company: this._company
|
||||
? this._company.toJson({
|
||||
includeAllContacts: true,
|
||||
includeAddress: true,
|
||||
includePrimaryContact: false,
|
||||
})
|
||||
: this.companyCwId
|
||||
? { id: this.companyCwId, name: this.companyName }
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
site: this.siteCwId ? { id: this.siteCwId, name: this.siteName } : null,
|
||||
site: this._siteData
|
||||
? this._siteData
|
||||
: this.siteCwId
|
||||
? { id: this.siteCwId, name: this.siteName }
|
||||
: null,
|
||||
customerPO: this.customerPO,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
location: this.locationCwId
|
||||
@@ -285,6 +753,8 @@ export class OpportunityController {
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
customFields: this._customFields ?? [],
|
||||
activities: this._activities?.map((a) => a.toJson()) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default class UserController {
|
||||
public login: string;
|
||||
public email: string;
|
||||
public image: string | null;
|
||||
public cwIdentifier: string | null;
|
||||
|
||||
private _roles: Collection<string, Role>;
|
||||
private _permissions: string | null;
|
||||
@@ -31,6 +32,7 @@ export default class UserController {
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
this._permissions = userdata.permissions ?? null;
|
||||
@@ -57,6 +59,7 @@ export default class UserController {
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
}
|
||||
@@ -314,6 +317,7 @@ export default class UserController {
|
||||
})(),
|
||||
login: opts?.safeReturn ? undefined : this.login,
|
||||
email: opts?.safeReturn ? undefined : this.email,
|
||||
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
|
||||
image: this.image,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
|
||||
Reference in New Issue
Block a user