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 | 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 { 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} */ public async fetchCompany(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // Build a map of forecastDetailId → procurement product cancellation data const cancellationMap = new Map>(); 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 => 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 { // TODO: implement } /** * Set Internal Approved * * The quote has been approved and is ready to be sent out. */ public async setInternalApproved(): Promise { // TODO: implement } /** * Set Quote Sent * * The quote has been sent to the customer. */ public async setQuoteSent(): Promise { // TODO: implement } /** * Set Quote Confirmed * * The quote has been received by the customer. */ public async setQuoteConfirmed(): Promise { // TODO: implement } /** * Set Revision Needed * * The quote needs to be revised and is set to stage revision. */ public async setRevisionNeeded(): Promise { // 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 { // TODO: implement } /** * Convert * * Converts the quote to a ticket and updates all necessary fields. */ public async convert(): Promise { // 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 { // 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, ): Promise { 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 { // 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 { 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 { 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 { 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 { 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 { 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, ): Promise { 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 { 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 { 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 { 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 { await opportunityCw.deleteNote(this.cwOpportunityId, noteId); await invalidateNotesCache(this.cwOpportunityId); } /** * To JSON * * Serializes the opportunity into a safe, API-friendly object. */ public toJson(): Record { 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()) ?? [], }; } }