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:
@@ -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()) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user