Files
optima/src/controllers/OpportunityController.ts
T

1609 lines
50 KiB
TypeScript

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 { 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,
CWForecastItemCreate,
CWOpportunity,
CWOpportunityNote,
CWOpportunityUpdate,
CWProcurementProduct,
CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types";
import {
resolveMember,
resolveMembers,
getMemberCache,
} from "../modules/cw-utils/members/memberCache";
import { ForecastProductController } from "./ForecastProductController";
import GenericError from "../Errors/GenericError";
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
import UserController from "./UserController";
import {
getCachedNotes,
getCachedContacts,
getCachedProducts,
getCachedSite,
fetchAndCacheNotes,
fetchAndCacheContacts,
fetchAndCacheProducts,
fetchAndCacheSite,
invalidateNotesCache,
invalidateProductsCache,
} from "../modules/cache/opportunityCache";
import {
generateQuote as generateQuotePdf,
type QuoteMetadata,
} from "../modules/pdf-utils";
import { generatedQuotes } from "../managers/generatedQuotes";
/**
* Opportunity Controller
*
* Domain model class that encapsulates an Opportunity entity and provides
* methods for accessing, refreshing from ConnectWise, and serializing
* opportunity data.
*/
export class OpportunityController {
public readonly id: string;
public readonly cwOpportunityId: number;
public name: string;
public notes: string | null;
public typeName: string | null;
public typeCwId: number | null;
public stageName: string | null;
public stageCwId: number | null;
public statusName: string | null;
public statusCwId: number | null;
public priorityName: string | null;
public priorityCwId: number | null;
public ratingName: string | null;
public ratingCwId: number | null;
public source: string | null;
public campaignName: string | null;
public campaignCwId: number | null;
public primarySalesRepName: string | null;
public primarySalesRepIdentifier: string | null;
public primarySalesRepCwId: number | null;
public secondarySalesRepName: string | null;
public secondarySalesRepIdentifier: string | null;
public secondarySalesRepCwId: number | null;
public companyCwId: number | null;
public companyName: string | null;
public contactCwId: number | null;
public contactName: string | null;
public siteCwId: number | null;
public siteName: string | null;
public customerPO: string | null;
public totalSalesTax: number;
public probability: number;
public locationName: string | null;
public locationCwId: number | null;
public departmentName: string | null;
public departmentCwId: number | null;
public expectedCloseDate: Date | null;
public pipelineChangeDate: Date | null;
public dateBecameLead: Date | null;
public closedDate: Date | null;
public closedFlag: boolean;
public closedByName: string | null;
public closedByCwId: number | null;
public companyId: string | null;
public cwLastUpdated: Date | null;
// Local product display order — array of CW forecast item IDs.
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
public productSequence: number[];
public readonly createdAt: Date;
public updatedAt: Date;
private _company: CompanyController | null = null;
private _siteData: ReturnType<typeof serializeCwSite> | null = null;
private _customFields: CWCustomField[] | null = null;
private _activities: ActivityController[] | null = null;
/** Compute the sub-resource cache TTL from this opportunity's fields. */
private _subResourceTTL(): number | null {
return computeSubResourceCacheTTL({
closedFlag: this.closedFlag,
closedDate: this.closedDate,
expectedCloseDate: this.expectedCloseDate,
lastUpdated: this.cwLastUpdated,
});
}
/** Compute the products-specific cache TTL from this opportunity's fields. */
private _productsTTL(): number | null {
return computeProductsCacheTTL({
closedFlag: this.closedFlag,
closedDate: this.closedDate,
expectedCloseDate: this.expectedCloseDate,
lastUpdated: this.cwLastUpdated,
statusCwId: this.statusCwId,
});
}
/**
* Resolve primary sales rep info for quote generation.
*
* Looks up the primary sales rep in the CW member cache and returns
* their name and email. Returns undefined if no rep is assigned.
*/
private async _resolveSalesRep(): Promise<
{ name: string; email?: string } | undefined
> {
if (!this.primarySalesRepIdentifier) return undefined;
const cache = await getMemberCache();
const member = cache.get(this.primarySalesRepIdentifier);
const name = member
? `${member.firstName} ${member.lastName}`.trim() ||
this.primarySalesRepName ||
this.primarySalesRepIdentifier
: (this.primarySalesRepName ?? this.primarySalesRepIdentifier);
return {
name,
email: member?.officeEmail ?? undefined,
};
}
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;
this.notes = data.notes;
this.typeName = data.typeName;
this.typeCwId = data.typeCwId;
this.stageName = data.stageName;
this.stageCwId = data.stageCwId;
this.statusName = data.statusName;
this.statusCwId = data.statusCwId;
this.priorityName = data.priorityName;
this.priorityCwId = data.priorityCwId;
this.ratingName = data.ratingName;
this.ratingCwId = data.ratingCwId;
this.source = data.source;
this.campaignName = data.campaignName;
this.campaignCwId = data.campaignCwId;
this.primarySalesRepName = data.primarySalesRepName;
this.primarySalesRepIdentifier = data.primarySalesRepIdentifier;
this.primarySalesRepCwId = data.primarySalesRepCwId;
this.secondarySalesRepName = data.secondarySalesRepName;
this.secondarySalesRepIdentifier = data.secondarySalesRepIdentifier;
this.secondarySalesRepCwId = data.secondarySalesRepCwId;
this.companyCwId = data.companyCwId;
this.companyName = data.companyName;
this.contactCwId = data.contactCwId;
this.contactName = data.contactName;
this.siteCwId = data.siteCwId;
this.siteName = data.siteName;
this.customerPO = data.customerPO;
this.totalSalesTax = data.totalSalesTax;
this.probability = data.probability;
this.locationName = data.locationName;
this.locationCwId = data.locationCwId;
this.departmentName = data.departmentName;
this.departmentCwId = data.departmentCwId;
this.expectedCloseDate = data.expectedCloseDate;
this.pipelineChangeDate = data.pipelineChangeDate;
this.dateBecameLead = data.dateBecameLead;
this.closedDate = data.closedDate;
this.closedFlag = data.closedFlag;
this.closedByName = data.closedByName;
this.closedByCwId = data.closedByCwId;
this.companyId = data.companyId;
this.cwLastUpdated = data.cwLastUpdated;
this.productSequence = data.productSequence;
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;
}
/**
* Hydrate Custom Fields
*
* Lazily fetches the opportunity's custom fields from ConnectWise
* if they haven't been loaded yet.
*/
private async _hydrateCustomFields(): Promise<void> {
if (this._customFields !== null) return;
const cwData = await fetchOpportunity(this.cwOpportunityId);
this._customFields = cwData.customFields ?? [];
}
/**
* 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;
}
/**
* Refresh from ConnectWise
*
* Fetches the latest opportunity data from CW and updates
* the local database record and controller state.
*/
public async refreshFromCW(): Promise<OpportunityController> {
const cwData = await fetchOpportunity(this.cwOpportunityId);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
}
/**
* Update Opportunity
*
* Patches the opportunity in ConnectWise with the provided fields,
* then syncs the updated data back to the local database.
*
* @param data — Partial fields to update on the CW opportunity
* @returns A fresh OpportunityController with the updated data
*/
public async updateOpportunity(
data: CWOpportunityUpdate,
): Promise<OpportunityController> {
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
}
/**
* Fetch raw CW data
*
* Returns the raw ConnectWise opportunity object without updating the DB.
*/
public async fetchCwData(): Promise<CWOpportunity> {
return fetchOpportunity(this.cwOpportunityId);
}
/**
* Map CW Opportunity → Prisma create/update payload
*
* Static helper used by both the controller and the refresh sync.
*/
public static mapCwToDb(item: CWOpportunity) {
return {
name: item.name,
notes: item.notes ?? null,
typeName: item.type?.name ?? null,
typeCwId: item.type?.id ?? null,
stageName: item.stage?.name ?? null,
stageCwId: item.stage?.id ?? null,
statusName: item.status?.name ?? null,
statusCwId: item.status?.id ?? null,
priorityName: item.priority?.name ?? null,
priorityCwId: item.priority?.id ?? null,
ratingName: item.rating?.name ?? null,
ratingCwId: item.rating?.id ?? null,
source: item.source ?? null,
campaignName: item.campaign?.name ?? null,
campaignCwId: item.campaign?.id ?? null,
primarySalesRepName: item.primarySalesRep?.name ?? null,
primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
primarySalesRepCwId: item.primarySalesRep?.id ?? null,
secondarySalesRepName: item.secondarySalesRep?.name ?? null,
secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null,
secondarySalesRepCwId: item.secondarySalesRep?.id ?? null,
companyCwId: item.company?.id ?? null,
companyName: item.company?.name ?? null,
contactCwId: item.contact?.id ?? null,
contactName: item.contact?.name ?? null,
siteCwId: item.site?.id ?? null,
siteName: item.site?.name ?? null,
customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0,
probability: Number(item.probability?.name) || 0,
locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null,
departmentName: item.department?.name ?? null,
departmentCwId: item.department?.id ?? null,
expectedCloseDate: item.expectedCloseDate
? new Date(item.expectedCloseDate)
: null,
pipelineChangeDate: item.pipelineChangeDate
? new Date(item.pipelineChangeDate)
: null,
dateBecameLead: item.dateBecameLead
? new Date(item.dateBecameLead)
: null,
closedDate: item.closedDate ? new Date(item.closedDate) : null,
closedFlag: item.closedFlag ?? false,
closedByName: item.closedBy?.name ?? null,
closedByCwId: item.closedBy?.id ?? null,
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
};
}
/**
* Fetch Site
*
* Fetches the full site details (address, phone, flags) from ConnectWise
* for the site associated with this opportunity.
* Checks the Redis cache first (30-min TTL); on miss, calls CW and caches.
* 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;
// Try cache first
const cached = await getCachedSite(this.companyCwId, this.siteCwId);
if (cached) {
this._siteData = serializeCwSite(cached);
return this._siteData;
}
// Cache miss — fetch from CW and cache
const cwSite = await fetchAndCacheSite(this.companyCwId, this.siteCwId);
if (!cwSite) return null;
this._siteData = serializeCwSite(cwSite);
return this._siteData;
}
/**
* Fetch Contacts
*
* Fetches contacts associated with this opportunity. Checks the Redis
* cache first; on miss, calls ConnectWise and caches the raw response.
*
* @param opts.fresh - Bypass cache and fetch directly from CW.
*/
public async fetchContacts(opts?: { fresh?: boolean }) {
const ttl = this._subResourceTTL();
// Try cache first (unless forced fresh)
if (!opts?.fresh && ttl !== null) {
const cached = await getCachedContacts(this.cwOpportunityId);
if (cached) return this._serializeContacts(cached);
}
// Fetch from CW (fetchAndCache* handles 404 internally)
try {
const contacts =
ttl !== null
? await fetchAndCacheContacts(this.cwOpportunityId, ttl)
: await opportunityCw.fetchContacts(this.cwOpportunityId);
return this._serializeContacts(contacts);
} catch (err: any) {
if (err?.isAxiosError && err?.response?.status === 404) return [];
throw err;
}
}
/** Serialize raw CW contact data into the API response shape. */
private _serializeContacts(contacts: any[]) {
return contacts.map((ct: any) => ({
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. Checks the Redis
* cache first; on miss, calls ConnectWise and caches the raw response.
*
* @param opts.fresh - Bypass cache and fetch directly from CW.
*/
public async fetchNotes(opts?: { fresh?: boolean }) {
const ttl = this._subResourceTTL();
// Try cache first (unless forced fresh)
if (!opts?.fresh && ttl !== null) {
const cached = await getCachedNotes(this.cwOpportunityId);
if (cached) return this._serializeNotes(cached);
}
// Fetch from CW (fetchAndCache* handles 404 internally)
try {
const notes =
ttl !== null
? await fetchAndCacheNotes(this.cwOpportunityId, ttl)
: await opportunityCw.fetchNotes(this.cwOpportunityId);
return this._serializeNotes(notes);
} catch (err: any) {
if (err?.isAxiosError && err?.response?.status === 404) return [];
throw err;
}
}
/** Serialize raw CW note data into the API response shape. */
private async _serializeNotes(notes: any[]) {
// Batch-resolve all member identifiers in a single DB query
const identifiers = notes
.map((n: any) => n.enteredBy as string)
.filter(Boolean);
const memberMap = await resolveMembers(identifiers);
return notes.map((n: any) => ({
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: memberMap.get(n.enteredBy) ?? {
id: null,
identifier: n.enteredBy,
name: n.enteredBy,
cwMemberId: null,
},
}));
}
/**
* 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.
* Checks the Redis cache first; on miss, calls ConnectWise and
* caches the raw response using the products-specific TTL algorithm.
*
* @param opts.fresh - Bypass cache and fetch directly from CW.
*/
public async fetchProducts(opts?: {
fresh?: boolean;
}): Promise<ForecastProductController[]> {
const ttl = this._productsTTL();
let forecast: any;
let procProducts: any[];
// Try cache first (unless forced fresh)
if (!opts?.fresh && ttl !== null) {
const cached = await getCachedProducts(this.cwOpportunityId);
if (cached) {
forecast = cached.forecast;
procProducts = cached.procProducts;
} else {
// Cache miss — fetch from CW and cache
const blob = await fetchAndCacheProducts(this.cwOpportunityId, ttl);
forecast = blob.forecast;
procProducts = blob.procProducts;
}
} else {
// No caching (won/lost/pending or forced fresh) — fetch directly
try {
[forecast, procProducts] = await Promise.all([
opportunityCw.fetchProducts(this.cwOpportunityId),
opportunityCw.fetchProcurementProducts(this.cwOpportunityId),
]);
} catch (err: any) {
if (err?.isAxiosError && err?.response?.status === 404) return [];
throw err;
}
}
return this._buildProductControllers(forecast, procProducts);
}
/**
* Generate Quote PDF
*
* Builds a customer-facing quote PDF using the opportunity, company, site,
* and product data available to this controller.
*/
public async generateQuote(opts?: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
showPreview?: boolean; // INTERNAL ONLY
logoPath?: string;
metadata?: QuoteMetadata;
}): Promise<Buffer> {
const options = {
lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true,
showPreview: opts?.showPreview ?? false,
logoPath: opts?.logoPath,
};
const products = await this.fetchProducts();
const activeProducts = products.filter(
(item) => item.includeFlag && item.cancellationType !== "full",
);
if (activeProducts.length === 0) {
throw new GenericError({
status: 400,
name: "QuoteGenerationError",
message: "Cannot generate a quote with no included line items",
});
}
const company = await this.fetchCompany();
const companyJson = company?.toJson({
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: false,
});
const site = await this.fetchSite();
const siteAddress = [
site?.address?.line1,
site?.address?.line2,
[site?.address?.city, site?.address?.state, site?.address?.zip]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const companyAddress = [
companyJson?.cw_Data?.address?.line1,
companyJson?.cw_Data?.address?.line2,
[
companyJson?.cw_Data?.address?.city,
companyJson?.cw_Data?.address?.state,
companyJson?.cw_Data?.address?.zip,
]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const addressLines = siteAddress.length > 0 ? siteAddress : companyAddress;
const lineItems = activeProducts.map((item) => {
const isLabor = item.productClass === "Service";
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
const itemNarrative = item.productNarrative || null;
const shouldIncludeNarrative =
options.includeItemNarratives && !!itemNarrative;
return {
qty: isLabor ? 1 : quantity,
description: item.productDescription || "Line Item",
unitPrice,
narrative: shouldIncludeNarrative ? itemNarrative : undefined,
};
});
const quoteDescription = this.name;
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const customerName =
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer";
const subTotal = lineItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice,
0,
);
const normalizedTaxRate =
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
const taxLabel =
normalizedTaxRate > 0
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
: "Sales Tax";
await this._hydrateCustomFields();
const quoteNarrative = options.includeQuoteNarrative
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
undefined
: undefined;
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
// Only show attention if it differs from the customer name
const attention =
this.contactName && this.contactName !== customerName
? this.contactName
: undefined;
// Only show company if it's meaningfully different from the customer name
// (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
const normalise = (s: string) =>
s
.toLowerCase()
.replace(/[,.\s]+/g, " ")
.trim()
.split(" ")
.sort()
.join(" ");
const showCompany = normalise(companyLine) !== normalise(customerName);
return generateQuotePdf(
{
customer: {
name: customerName,
company: showCompany ? companyLine : undefined,
attention,
address:
addressLines.length > 0 ? addressLines : ["Address unavailable"],
},
contact: {
email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
},
salesRep: await this._resolveSalesRep(),
quote: {
quoteNumber: this.cwOpportunityId.toString(),
date: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
description: quoteDescription,
},
lineItems,
quoteNarrative,
tax: {
rate: normalizedTaxRate,
label: taxLabel,
},
isPreview: options.showPreview,
showLineItemPricing: options.lineItemPricing,
metadata: opts?.metadata,
},
{},
options.logoPath,
);
}
/**
* Commit Quote
*
* Generates a non-preview quote PDF and stores it in the GeneratedQuotes
* table with a full data snapshot for exact reproduction, regeneration
* metadata, and creator attribution.
*/
public async commitQuote(
opts: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
logoPath?: string;
} = {},
user: UserController,
) {
const quoteOptions = {
lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true,
logoPath: opts?.logoPath,
};
// ── Fetch all data sources BEFORE generating ──────────────────────
const products = await this.fetchProducts();
const company = await this.fetchCompany();
const companyJson = company?.toJson({
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: false,
});
const site = await this.fetchSite();
const salesRep = await this._resolveSalesRep();
await this._hydrateCustomFields();
const quoteNarrative = quoteOptions.includeQuoteNarrative
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
null)
: null;
// ── Pre-generate IDs & timestamps for metadata ───────────────────
const quoteId = crypto.randomUUID();
const createdAt = new Date().toISOString();
// ── Generate the PDF ──────────────────────────────────────────────
const quoteBuffer = await this.generateQuote({
...quoteOptions,
showPreview: false,
metadata: {
quoteId,
createdById: user.id,
createdByName: user.name ?? undefined,
createdByEmail: user.email ?? undefined,
createdAt,
},
});
const fileTimestamp = createdAt.replace(/[:.]/g, "-");
const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;
// ── Build the full data snapshot ──────────────────────────────────
const siteAddress = [
site?.address?.line1,
site?.address?.line2,
[site?.address?.city, site?.address?.state, site?.address?.zip]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const companyAddress = [
companyJson?.cw_Data?.address?.line1,
companyJson?.cw_Data?.address?.line2,
[
companyJson?.cw_Data?.address?.city,
companyJson?.cw_Data?.address?.state,
companyJson?.cw_Data?.address?.zip,
]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const regenData = {
// Generation options
options: {
lineItemPricing: quoteOptions.lineItemPricing,
includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
includeItemNarratives: quoteOptions.includeItemNarratives,
},
// Opportunity metadata
opportunity: {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
totalSalesTax: this.totalSalesTax,
contactName: this.contactName,
companyName: this.companyName,
},
// Customer / company / site snapshot
customer: {
preparedFor:
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer",
companyName: this.companyName ?? company?.name ?? null,
primaryContact: companyJson?.cw_Data?.primaryContact
? {
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
email: companyJson.cw_Data.primaryContact.email ?? null,
phone: companyJson.cw_Data.primaryContact.phone ?? null,
}
: null,
siteAddress: siteAddress.length > 0 ? siteAddress : null,
companyAddress: companyAddress.length > 0 ? companyAddress : null,
},
// Sales rep snapshot
salesRep: salesRep ?? null,
// Quote narrative
quoteNarrative: quoteNarrative ?? null,
// Full product snapshot
products: products.map((p) => ({
cwForecastId: p.cwForecastId,
forecastDescription: p.forecastDescription,
productDescription: p.productDescription,
customerDescription: p.customerDescription,
productNarrative: p.productNarrative,
productClass: p.productClass,
forecastType: p.forecastType,
catalogItem: p.catalogItemCwId
? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
: null,
quantity: p.quantity,
effectiveQuantity: p.effectiveQuantity,
revenue: p.revenue,
cost: p.cost,
margin: p.margin,
percentage: p.percentage,
includeFlag: p.includeFlag,
taxableFlag: p.taxableFlag,
recurringFlag: p.recurringFlag,
recurringRevenue: p.recurringRevenue,
recurringCost: p.recurringCost,
sequenceNumber: p.sequenceNumber,
cancelledFlag: p.cancelledFlag,
cancellationType: p.cancellationType,
quantityCancelled: p.quantityCancelled,
cancelledReason: p.cancelledReason,
cancelledDate: p.cancelledDate,
})),
// Timestamp of when this snapshot was taken
snapshotTimestamp: new Date().toISOString(),
};
const regenParams = {
opportunityId: this.id,
cwOpportunityId: this.cwOpportunityId,
};
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(JSON.stringify({ regenData, regenParams }));
const quoteRegenHash = hasher.digest("hex");
return generatedQuotes.create({
id: quoteId,
quoteRegenData: regenData,
quoteRegenParams: regenParams,
quoteRegenHash,
quoteFile: quoteBuffer,
quoteFileName,
opportunityId: this.id,
createdById: user.id,
});
}
/**
* Build ForecastProductController[] from raw CW data.
*
* Extracted from fetchProducts() so both cached and fresh paths
* share the same ordering + enrichment logic.
*/
private async _buildProductControllers(
forecast: any,
procProducts: any[],
): Promise<ForecastProductController[]> {
// Build a map of forecastDetailId → procurement product cancellation data
const cancellationMap = new Map<number, Record<string, unknown>>();
for (const pp of procProducts) {
const rawForecastDetailId = (pp as any)?.forecastDetailId;
const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
cancellationMap.set(forecastDetailId, pp);
}
}
// Procurement-only view: only include forecast items that have a
// matching procurement record (via forecastDetailId).
const forecastItems = (forecast.forecastItems ?? []).filter((fi: any) =>
cancellationMap.has(fi.id),
);
// Apply local ordering if productSequence is set, otherwise fall back
// to CW sequenceNumber.
let ordered: typeof forecastItems;
if (this.productSequence.length > 0) {
const itemById = new Map(forecastItems.map((fi: any) => [fi.id, fi]));
// Items in the specified order first, then any new items not yet sequenced
const sequenced = this.productSequence
.map((id) => itemById.get(id))
.filter((fi: any): fi is NonNullable<typeof fi> => fi !== undefined);
const sequencedIds = new Set(this.productSequence);
const unsequenced = forecastItems
.filter((fi: any) => !sequencedIds.has(fi.id))
.sort((a: any, b: any) => a.sequenceNumber - b.sequenceNumber);
ordered = [...sequenced, ...unsequenced];
} else {
ordered = [...forecastItems].sort(
(a: any, b: any) => a.sequenceNumber - b.sequenceNumber,
);
}
const controllers: ForecastProductController[] = ordered.map(
(item: any) => {
const ctrl = new ForecastProductController(item);
const procData = cancellationMap.get(item.id);
if (procData) {
ctrl.applyCancellationData(procData as any);
ctrl.applyProcurementCustomFields(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,
);
await invalidateProductsCache(this.cwOpportunityId);
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
*
* Stores the desired display order of forecast item IDs locally in
* the database. No CW API calls are made — CW item IDs are stable
* and ordering is applied when `fetchProducts()` is called.
*
* @param orderedIds - Forecast item IDs in the desired display order
*/
public async resequenceProducts(
orderedIds: number[],
): Promise<ForecastProductController[]> {
// Validate all IDs exist in CW
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
const existingIds = new Set(
(forecast.forecastItems ?? []).map((fi) => fi.id),
);
for (const id of orderedIds) {
if (!existingIds.has(id)) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${id} not found on opportunity ${this.cwOpportunityId}`,
});
}
}
// Persist the sequence locally
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: orderedIds },
});
this.productSequence = orderedIds;
// Invalidate cached products since ordering changed
await invalidateProductsCache(this.cwOpportunityId);
// Return items in the new order
return this.fetchProducts();
}
/**
* Append Product Sequence IDs
*
* Adds newly created forecast item IDs to the end of the local
* productSequence array, preserving existing order and avoiding duplicates.
*/
private async appendProductSequenceIds(ids: number[]): Promise<void> {
const normalizedIds = ids.filter(
(id): id is number => Number.isInteger(id) && id > 0,
);
if (normalizedIds.length === 0) return;
const current = await prisma.opportunity.findUnique({
where: { id: this.id },
select: { productSequence: true },
});
const existing = current?.productSequence ?? [];
const existingSet = new Set(existing);
const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
if (idsToAppend.length === 0) {
this.productSequence = existing;
return;
}
const updatedSequence = [...existing, ...idsToAppend];
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: updatedSequence },
});
this.productSequence = updatedSequence;
}
/**
* Add Products
*
* Adds one or more products/line items to this opportunity via the
* ConnectWise forecast endpoint. The caller passes only the fields
* the user is permitted to set (already filtered by field-level
* permission gating in the route handler).
*
* Accepts a single item or an array of items.
*/
public async addProducts(
data: CWForecastItemCreate | CWForecastItemCreate[],
): Promise<ForecastProductController[]> {
try {
const created = await opportunityCw.createProducts(
this.cwOpportunityId,
data,
);
await this.appendProductSequenceIds(created.map((item) => item.id));
await invalidateProductsCache(this.cwOpportunityId);
return created.map((item) => new ForecastProductController(item));
} catch (err: any) {
console.error(
`[addProducts] Failed to create forecast item(s) 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 new GenericError({
status: err?.response?.status ?? 500,
name: "AddProductFailed",
message:
err?.response?.data?.message ??
"Failed to add product(s) to opportunity",
cause: err?.message,
});
}
}
/**
* Add Procurement Products
*
* Creates one or more procurement products linked to this opportunity.
* Use this when payloads include procurement-only fields such as customFields.
*/
public async addProcurementProducts(
data: CWProcurementProductCreate | CWProcurementProductCreate[],
): Promise<CWProcurementProduct[]> {
try {
const items = Array.isArray(data) ? data : [data];
const normalized = items.map((item) => ({
...item,
opportunity: { id: this.cwOpportunityId },
}));
const created = await opportunityCw.createProcurementProducts(normalized);
await this.appendProductSequenceIds(
created
.map((item) => item.forecastDetailId)
.filter((id): id is number => typeof id === "number"),
);
await invalidateProductsCache(this.cwOpportunityId);
return created;
} catch (err: any) {
console.error(
`[addProcurementProducts] Failed to create procurement product(s) 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 new GenericError({
status: err?.response?.status ?? 500,
name: "AddProcurementProductFailed",
message:
err?.response?.data?.message ??
"Failed to add procurement product(s) to opportunity",
cause: err?.message,
});
}
}
/**
* Delete Product
*
* Removes a forecast item from this opportunity in ConnectWise,
* removes the item ID from the local productSequence, and
* invalidates the products cache.
*
* @param forecastItemId - The CW forecast item ID to delete
*/
public async deleteProduct(forecastItemId: number): Promise<void> {
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);
// Remove the deleted item from the local product sequence
if (this.productSequence.includes(forecastItemId)) {
const updatedSequence = this.productSequence.filter(
(id) => id !== forecastItemId,
);
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: updatedSequence },
});
this.productSequence = updatedSequence;
}
await invalidateProductsCache(this.cwOpportunityId);
}
/**
* Fetch Procurement Product By Forecast Item
*
* Returns the linked procurement product for a forecast item ID,
* or null when no procurement record exists.
*/
public async fetchProcurementProductByForecastItem(
forecastItemId: number,
): Promise<CWProcurementProduct | null> {
return opportunityCw.fetchProcurementProductByForecastDetail(
this.cwOpportunityId,
forecastItemId,
);
}
/**
* Update Procurement Product By Forecast Item
*
* Finds the linked procurement product for a forecast item and updates it.
* Returns null when no linked procurement product exists.
*/
public async updateProcurementProductByForecastItem(
forecastItemId: number,
data: Record<string, unknown>,
): Promise<CWProcurementProduct | null> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) return null;
const updated = await opportunityCw.updateProcurementProduct(
linked.id,
data,
);
await invalidateProductsCache(this.cwOpportunityId);
return updated;
}
/**
* Set Product Cancellation
*
* Updates cancellation fields on the procurement product linked to a
* forecast item. A quantity of 0 is treated as uncancelled.
*/
public async setProductCancellation(
forecastItemId: number,
opts: { quantityCancelled: number; cancellationReason?: string | null },
): Promise<CWProcurementProduct> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
const cancelledFlag = quantityCancelled > 0;
const updated = await this.updateProcurementProductByForecastItem(
forecastItemId,
{
quantityCancelled,
cancelledFlag,
cancelledReason: cancelledFlag
? (opts.cancellationReason ?? null)
: null,
},
);
if (!updated) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
return updated;
}
/**
* 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,
});
await invalidateNotesCache(this.cwOpportunityId);
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,
);
await invalidateNotesCache(this.cwOpportunityId);
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);
await invalidateNotesCache(this.cwOpportunityId);
}
/**
* To JSON
*
* Serializes the opportunity into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
description: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
stage: this.stageCwId
? { id: this.stageCwId, name: this.stageName }
: null,
status: this.statusCwId
? { id: this.statusCwId, name: this.statusName }
: null,
priority: this.priorityCwId
? { id: this.priorityCwId, name: this.priorityName }
: null,
rating: this.ratingCwId
? { id: this.ratingCwId, name: this.ratingName }
: null,
source: this.source,
campaign: this.campaignCwId
? { id: this.campaignCwId, name: this.campaignName }
: null,
primarySalesRep: this.primarySalesRepCwId
? {
id: this.primarySalesRepCwId,
identifier: this.primarySalesRepIdentifier,
name: this.primarySalesRepName,
}
: null,
secondarySalesRep: this.secondarySalesRepCwId
? {
id: this.secondarySalesRepCwId,
identifier: this.secondarySalesRepIdentifier,
name: this.secondarySalesRepName,
}
: 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._siteData
? this._siteData
: this.siteCwId
? { id: this.siteCwId, name: this.siteName }
: null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
probability: this.probability,
location: this.locationCwId
? { id: this.locationCwId, name: this.locationName }
: null,
department: this.departmentCwId
? { id: this.departmentCwId, name: this.departmentName }
: null,
expectedCloseDate: this.expectedCloseDate,
pipelineChangeDate: this.pipelineChangeDate,
dateBecameLead: this.dateBecameLead,
closedDate: this.closedDate,
closedFlag: this.closedFlag,
closedBy: this.closedByCwId
? { id: this.closedByCwId, name: this.closedByName }
: null,
companyId: this.companyId,
cwLastUpdated: this.cwLastUpdated,
productSequence: this.productSequence,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
customFields: this._customFields ?? [],
activities: this._activities?.map((a) => a.toJson()) ?? [],
};
}
}