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:
2026-03-01 13:19:00 -06:00
parent 883b648d5e
commit d7b374f8ab
96 changed files with 7752 additions and 205 deletions
+477 -7
View File
@@ -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()) ?? [],
};
}
}