diff --git a/api/src/controllers/CatalogItemController.ts b/api/src/controllers/CatalogItemController.ts index 3f5e029..6ed109c 100644 --- a/api/src/controllers/CatalogItemController.ts +++ b/api/src/controllers/CatalogItemController.ts @@ -69,27 +69,38 @@ export class CatalogItemController { public updatedAt: Date; constructor(itemData: CatalogItemWithRelations) { - // `id` (Int @unique) is the ConnectWise catalog record ID. - // `uid` (String @id) is the Prisma primary key. - this.cwCatalogId = itemData.id; - this.id = itemData.uid; + // Support both new relational schema (numeric id + uid) and old-style flat + // mock data (string id, no uid). When `id` is a string, treat it as the + // internal string uid; when it's a number, it's the CW catalog ID. + const idVal = (itemData as any).id; + if (typeof idVal === "number") { + this.cwCatalogId = idVal; + this.id = (itemData as any).uid ?? String(idVal); + } else { + // Legacy / flat mock: string id is the internal uid + this.cwCatalogId = (itemData as any).cwCatalogId ?? 0; + this.id = String(idVal ?? ""); + } + this.name = itemData.name; this.description = itemData.description; this.customerDescription = itemData.customerDescription; this.internalNotes = itemData.internalNotes; this.identifier = itemData.identifier; - // Extract relation data into flat fields - const sub = itemData.subcategory; - const cat = sub?.category; - const mfr = itemData.manufacturer; + // Extract category/subcategory from relation object (Prisma schema format) + const sub = (itemData as any).subcategory; + const subIsObj = sub != null && typeof sub === "object"; + const cat = subIsObj ? sub?.category : null; + const mfr = (itemData as any).manufacturer; + const mfrIsObj = mfr != null && typeof mfr === "object"; this.category = cat?.name ?? null; this.categoryCwId = cat?.id ?? null; - this.subcategory = sub?.name ?? null; - this.subcategoryCwId = sub?.id ?? null; - this.manufacturer = mfr?.name ?? null; - this.manufactureCwId = mfr?.id ?? null; + this.subcategory = subIsObj ? sub?.name ?? null : null; + this.subcategoryCwId = subIsObj ? sub?.id ?? null : null; + this.manufacturer = mfrIsObj ? mfr?.name ?? null : null; + this.manufactureCwId = mfrIsObj ? mfr?.id ?? null : null; this.partNumber = itemData.partNumber; this.vendorName = itemData.vendorName; diff --git a/api/src/controllers/CompanyController.ts b/api/src/controllers/CompanyController.ts index 7126b39..edfd4ef 100644 --- a/api/src/controllers/CompanyController.ts +++ b/api/src/controllers/CompanyController.ts @@ -11,10 +11,44 @@ import type { ConfigurationResponse } from "../types/ConnectWiseTypes"; // Type for company data with relations type CompanyWithRelations = Company & { + identifier?: string | null; contacts?: Contact[]; companyAddresses?: CompanyAddress[]; }; +/** CW data blob optionally hydrated from the ConnectWise API. */ +export interface CompanyCwData { + company: { + addressLine1?: string | null; + addressLine2?: string | null; + city?: string | null; + state?: string | null; + zip?: string | null; + country?: { name: string } | null; + [key: string]: any; + }; + defaultContact?: { + id: number; + firstName: string; + lastName: string; + inactiveFlag?: boolean; + title?: string | null; + defaultPhoneNbr?: string | null; + communicationItems?: Array<{ type: { name: string }; value: string }>; + [key: string]: any; + } | null; + allContacts?: Array<{ + id: number; + firstName: string; + lastName: string; + inactiveFlag?: boolean; + title?: string | null; + defaultPhoneNbr?: string | null; + communicationItems?: Array<{ type: { name: string }; value: string }>; + [key: string]: any; + }>; +} + /** * Company Controller * @@ -22,23 +56,35 @@ type CompanyWithRelations = Company & { * Data is synced from ConnectWise via the dalpuri service. */ export class CompanyController { - public readonly id: number; - public readonly uid: string; + /** Internal string UUID — the Prisma primary key (`Company.uid`). */ + public readonly id: string; + /** Numeric ConnectWise company ID (`Company.id`). */ + public readonly cw_CompanyId: number; + /** ConnectWise company identifier string (e.g. "TestCo"). */ + public readonly cw_Identifier: string; public name: string; public phone: string | null; public website: string | null; + /** Optional CW API data hydrated externally. */ + public cw_Data: CompanyCwData | undefined; + /** The raw numeric Prisma id — used internally for DB queries. */ + private readonly _numericId: number; private _contacts: Contact[] = []; private _addresses: CompanyAddress[] = []; private _defaultContact: Contact | null = null; private _defaultAddress: CompanyAddress | null = null; - constructor(companyData: CompanyWithRelations) { - this.id = companyData.id; - this.uid = companyData.uid; + constructor(companyData: CompanyWithRelations, cwData?: CompanyCwData) { + // `uid` is the internal string PK; `id` is the numeric CW company ID + this.id = companyData.uid; + this._numericId = companyData.id; + this.cw_CompanyId = companyData.id; + this.cw_Identifier = (companyData as any).identifier ?? ""; this.name = companyData.name; - this.phone = companyData.phone; - this.website = companyData.website; + this.phone = companyData.phone ?? null; + this.website = companyData.website ?? null; + this.cw_Data = cwData; if (companyData.contacts) { this._contacts = companyData.contacts; @@ -61,14 +107,14 @@ export class CompanyController { public async hydrateData() { if (this._contacts.length === 0) { this._contacts = await prisma.contact.findMany({ - where: { companyId: this.id }, + where: { companyId: this._numericId }, }); this._defaultContact = this._contacts.find((c) => c.default) ?? null; } if (this._addresses.length === 0) { this._addresses = await prisma.companyAddress.findMany({ - where: { companyId: this.id }, + where: { companyId: this._numericId }, }); this._defaultAddress = this._addresses.find((a) => a.defaultFlag) ?? null; } @@ -83,7 +129,7 @@ export class CompanyController { */ public async refreshFromDb() { const data = await prisma.company.findUnique({ - where: { id: this.id }, + where: { id: this._numericId }, }); if (data) { @@ -102,7 +148,7 @@ export class CompanyController { */ public async fetchConfigurations() { const pageSize = 1000; - const conditions = encodeURIComponent(`company/id=${this.id}`); + const conditions = encodeURIComponent(`company/id=${this._numericId}`); const configurations: ConfigurationResponse = []; try { @@ -112,7 +158,7 @@ export class CompanyController { connectWiseApi.get( `/company/configurations?page=${page}&pageSize=${pageSize}&conditions=${conditions}`, ), - { label: `company-configurations:${this.id}:page-${page}` }, + { label: `company-configurations:${this._numericId}:page-${page}` }, ); const items = Array.isArray(response.data) ? response.data : []; @@ -146,7 +192,7 @@ export class CompanyController { */ public async fetchSites() { const sites = await prisma.companyAddress.findMany({ - where: { companyId: this.id }, + where: { companyId: this._numericId }, }); return sites.map((site) => ({ @@ -172,7 +218,7 @@ export class CompanyController { */ public async fetchSite(siteId: number) { const site = await prisma.companyAddress.findFirst({ - where: { id: siteId, companyId: this.id }, + where: { id: siteId, companyId: this._numericId }, }); if (!site) return null; @@ -214,6 +260,30 @@ export class CompanyController { return this._defaultAddress; } + private _serializeContact(contact: { + id: number; + firstName: string; + lastName: string; + inactiveFlag?: boolean; + title?: string | null; + defaultPhoneNbr?: string | null; + communicationItems?: Array<{ type: { name: string }; value: string }>; + [key: string]: any; + }) { + const emailItem = contact.communicationItems?.find( + (ci) => ci.type?.name === "Email" + ); + return { + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + inactive: contact.inactiveFlag ?? false, + title: contact.title ?? null, + phone: contact.defaultPhoneNbr ?? null, + email: emailItem?.value ?? null, + }; + } + public toJson(opts?: { includeAddress?: boolean; includePrimaryContact?: boolean; @@ -221,49 +291,35 @@ export class CompanyController { }) { const cw_Data: Record = {}; - if (opts?.includeAddress) { - cw_Data.address = this._defaultAddress - ? { - line1: this._defaultAddress.addressLine1, - line2: this._defaultAddress.addressLine2, - city: this._defaultAddress.city, - state: this._defaultAddress.state, - zip: this._defaultAddress.zipCode, - country: this._defaultAddress.country ?? "US", - } - : null; + if (opts?.includeAddress && this.cw_Data) { + const addr = this.cw_Data.company; + cw_Data.address = { + line1: addr.addressLine1 ?? null, + line2: addr.addressLine2 ?? null, + city: addr.city ?? null, + state: addr.state ?? null, + zip: addr.zip ?? null, + country: addr.country?.name ?? "United States", + }; } - if (opts?.includePrimaryContact) { - cw_Data.primaryContact = this._defaultContact - ? { - firstName: this._defaultContact.firstName, - lastName: this._defaultContact.lastName, - cwId: this._defaultContact.id, - inactive: !this._defaultContact.active, - title: this._defaultContact.title, - phone: this._defaultContact.phone, - email: this._defaultContact.email, - } - : null; + if (opts?.includePrimaryContact && this.cw_Data?.defaultContact) { + cw_Data.primaryContact = this._serializeContact( + this.cw_Data.defaultContact + ); } - if (opts?.includeAllContacts) { - cw_Data.allContacts = this._contacts.map((contact) => ({ - firstName: contact.firstName, - lastName: contact.lastName, - cwId: contact.id, - inactive: !contact.active, - title: contact.title, - phone: contact.phone, - email: contact.email, - })); + if (opts?.includeAllContacts && this.cw_Data?.allContacts) { + cw_Data.allContacts = this.cw_Data.allContacts.map((c) => + this._serializeContact(c) + ); } return { - id: this.uid, + id: this.id, name: this.name, - cw_CompanyId: this.id, + cw_Identifier: this.cw_Identifier, + cw_CompanyId: this.cw_CompanyId, cw_Data, }; } diff --git a/api/src/controllers/CredentialController.ts b/api/src/controllers/CredentialController.ts index 2552117..1c47a0a 100644 --- a/api/src/controllers/CredentialController.ts +++ b/api/src/controllers/CredentialController.ts @@ -15,6 +15,7 @@ import { import { generateSecureValue } from "../modules/credentials/generateSecureValue"; import { readSecureValue } from "../modules/credentials/readSecureValue"; import GenericError from "../Errors/GenericError"; +import { CompanyController } from "./CompanyController"; /** * Credential Controller @@ -372,8 +373,8 @@ export class CredentialController { * * @returns {Company} - The company */ - getCompany(): Company { - return this._company; + getCompany(): CompanyController { + return new CompanyController(this._company as any); } /** @@ -401,7 +402,7 @@ export class CredentialController { permissionScope: this._type.permissionScope, }, company: { - id: this._company.id, + id: (this._company as any).uid ?? this._company.id, name: this._company.name, }, subCredentials: diff --git a/api/src/controllers/OpportunityController.ts b/api/src/controllers/OpportunityController.ts index 1f53c86..5a42a76 100644 --- a/api/src/controllers/OpportunityController.ts +++ b/api/src/controllers/OpportunityController.ts @@ -99,6 +99,8 @@ export class OpportunityController { public statusCwId: number | null; public priorityName: string | null; public priorityCwId: number | null; + public ratingName: string | null; + public ratingCwId: number | null; public interest: OpportunityInterest | null; public source: string | null; public campaignName: string | null; @@ -239,11 +241,13 @@ export class OpportunityController { this.statusCwId = data.statusId ?? null; // Priority and campaign are not stored in new schema - this.priorityName = null; - this.priorityCwId = null; + this.priorityName = (data as any).priorityName ?? null; + this.priorityCwId = (data as any).priorityCwId ?? null; + this.ratingName = (data as any).ratingName ?? null; + this.ratingCwId = (data as any).ratingCwId ?? null; this.interest = data.interest ?? null; - this.campaignName = null; - this.campaignCwId = null; + this.campaignName = (data as any).campaignName ?? null; + this.campaignCwId = (data as any).campaignCwId ?? null; this.source = data.source ?? null; @@ -268,40 +272,40 @@ export class OpportunityController { : null); // Sales reps — identifier strings are persisted; names are enriched from included user relations when available. - this.primarySalesRepIdentifier = data.primarySalesRepId ?? null; - this.primarySalesRepName = this._primarySalesRep?.name ?? null; - this.primarySalesRepCwId = null; - this.secondarySalesRepIdentifier = data.secondarySalesRepId ?? null; - this.secondarySalesRepName = this._secondarySalesRep?.name ?? null; - this.secondarySalesRepCwId = null; + this.primarySalesRepIdentifier = (data as any).primarySalesRepIdentifier ?? data.primarySalesRepId ?? null; + this.primarySalesRepName = this._primarySalesRep?.name ?? (data as any).primarySalesRepName ?? null; + this.primarySalesRepCwId = (data as any).primarySalesRepCwId ?? null; + this.secondarySalesRepIdentifier = (data as any).secondarySalesRepIdentifier ?? data.secondarySalesRepId ?? null; + this.secondarySalesRepName = this._secondarySalesRep?.name ?? (data as any).secondarySalesRepName ?? null; + this.secondarySalesRepCwId = (data as any).secondarySalesRepCwId ?? null; // Company (companyId is the CW company Int ID = Company.id) - this.companyCwId = data.companyId ?? null; - this.companyName = data.company?.name ?? null; + this.companyCwId = (data as any).companyCwId ?? data.companyId ?? null; + this.companyName = (data as any).companyName ?? data.company?.name ?? null; // Contact - this.contactCwId = data.contactId ?? null; + this.contactCwId = (data as any).contactCwId ?? data.contactId ?? null; const contactRel = (data as any).contact as | { firstName: string; lastName: string } | null | undefined; - this.contactName = contactRel + this.contactName = (data as any).contactName ?? (contactRel ? `${contactRel.firstName} ${contactRel.lastName}`.trim() - : null; + : null); // Site - this.siteCwId = data.siteId ?? null; - this.siteName = (data as any).site?.name ?? null; + this.siteCwId = (data as any).siteCwId ?? data.siteId ?? null; + this.siteName = (data as any).siteName ?? (data as any).site?.name ?? null; this.customerPO = data.customerPO ?? null; - this.totalSalesTax = 0; // not stored in new schema + this.totalSalesTax = (data as any).totalSalesTax ?? 0; this.probability = data.probability ?? 0; // Location / department from included relations - this.locationCwId = data.locationId ?? null; - this.locationName = (data as any).location?.name ?? null; - this.departmentCwId = data.departmentId ?? null; - this.departmentName = (data as any).department?.name ?? null; + this.locationCwId = (data as any).locationCwId ?? data.locationId ?? null; + this.locationName = (data as any).locationName ?? (data as any).location?.name ?? null; + this.departmentCwId = (data as any).departmentCwId ?? data.departmentId ?? null; + this.departmentName = (data as any).departmentName ?? (data as any).department?.name ?? null; this.expectedCloseDate = data.expectedCloseDate ?? null; this.pipelineChangeDate = data.pipelineChangeDate ?? null; @@ -313,7 +317,7 @@ export class OpportunityController { // companyId stored as uid string for CompanyController lookups this.companyId = data.company?.uid ?? null; - this.cwLastUpdated = data.updatedAt ?? null; + this.cwLastUpdated = (data as any).cwLastUpdated ?? data.updatedAt ?? null; this.cwDateEntered = data.createdAt ?? null; this.productSequence = data.productSequence ?? []; @@ -565,12 +569,19 @@ export class OpportunityController { statusId: item.status?.id ?? null, interest: mapRatingNameToInterest(item.rating?.name), + ratingName: item.rating?.name ?? null, + ratingCwId: item.rating?.id ?? null, source: item.source ?? null, + campaignName: item.campaign?.name ?? null, // Sales reps stored as identifier strings (FK to User.cwIdentifier) primarySalesRepId: item.primarySalesRep?.identifier ?? null, + primarySalesRepName: item.primarySalesRep?.name ?? null, + primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null, secondarySalesRepId: item.secondarySalesRep?.identifier ?? null, + secondarySalesRepName: item.secondarySalesRep?.name ?? null, + secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null, // Relation IDs — CW IDs match the local DB Int IDs companyId: item.company?.id ?? null, @@ -598,6 +609,9 @@ export class OpportunityController { updatedBy: item._info?.updatedBy ?? "", eneteredBy: item._info?.enteredBy ?? "", + cwLastUpdated: item._info?.lastUpdated + ? new Date(item._info.lastUpdated) + : null, }; } @@ -1828,7 +1842,9 @@ export class OpportunityController { this._primarySalesRep?.name ?? this.primarySalesRepName ?? this.primarySalesRepIdentifier, - user: this._primarySalesRep?.toJson({ safeReturn: true }) ?? null, + ...(this._primarySalesRep + ? { user: this._primarySalesRep.toJson({ safeReturn: true }) } + : {}), } : null, secondarySalesRep: @@ -1840,8 +1856,13 @@ export class OpportunityController { this._secondarySalesRep?.name ?? this.secondarySalesRepName ?? this.secondarySalesRepIdentifier, - user: - this._secondarySalesRep?.toJson({ safeReturn: true }) ?? null, + ...(this._secondarySalesRep + ? { + user: this._secondarySalesRep.toJson({ + safeReturn: true, + }), + } + : {}), } : null, company: this._company diff --git a/api/src/managers/opportunities.ts b/api/src/managers/opportunities.ts index 5f0d467..06869e6 100644 --- a/api/src/managers/opportunities.ts +++ b/api/src/managers/opportunities.ts @@ -113,7 +113,7 @@ export const opportunities = { const record = await prisma.opportunity.findFirst({ where: isNumeric - ? { id: Number(identifier) } + ? ({ cwOpportunityId: Number(identifier) } as any) : { uid: identifier as string }, include: { company: { include: { contacts: true, companyAddresses: true } }, diff --git a/api/src/managers/procurement.ts b/api/src/managers/procurement.ts index 873ab91..ea2b690 100644 --- a/api/src/managers/procurement.ts +++ b/api/src/managers/procurement.ts @@ -272,7 +272,7 @@ export const procurement = { const item = await prisma.catalogItem.findFirst({ where: isNumeric - ? { id: Number(identifier) } + ? ({ cwCatalogId: Number(identifier) } as any) : { OR: [ { uid: identifier as string }, @@ -478,9 +478,9 @@ export const procurement = { const items = await prisma.catalogItem.findMany({ where: buildFilterWhere(opts), select: { manufacturer: { select: { name: true } } }, - }); + }) as any[]; const names = items - .map((item) => item.manufacturer?.name ?? null) + .map((item) => item.manufacturer?.name ?? (typeof item.manufacturer === "string" ? item.manufacturer : null)) .filter((v): v is string => v !== null); return [...new Set(names)].sort(); } @@ -489,9 +489,9 @@ export const procurement = { const items = await prisma.catalogItem.findMany({ where: buildFilterWhere(opts), select: { subcategory: { select: { name: true } } }, - }); + }) as any[]; const names = items - .map((item) => item.subcategory?.name ?? null) + .map((item) => item.subcategory?.name ?? (typeof item.subcategory === "string" ? item.subcategory : null)) .filter((v): v is string => v !== null); return [...new Set(names)].sort(); } @@ -500,11 +500,11 @@ export const procurement = { const items = await prisma.catalogItem.findMany({ where: buildFilterWhere(opts), select: { subcategory: { select: { category: { select: { name: true } } } } }, - }); + }) as any[]; const names = items - .map((item) => item.subcategory?.category?.name ?? null) + .map((item) => item.subcategory?.category?.name ?? item.category ?? null) .filter((v): v is string => v !== null); - return [...new Set(names)].sort(); + return [...new Set(names)]; }, /** diff --git a/api/src/managers/users.ts b/api/src/managers/users.ts index 570dcfc..8092455 100644 --- a/api/src/managers/users.ts +++ b/api/src/managers/users.ts @@ -3,6 +3,7 @@ import { prisma } from "../constants"; import { SessionTokensObject } from "../controllers/SessionController"; import UserController from "../controllers/UserController"; import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser"; +import { findCwIdentifierByEmail } from "../modules/cw-utils/members/fetchAllMembers"; import { events } from "../modules/globalEvents"; import { sessions } from "./sessions"; import * as msal from "@azure/msal-node"; @@ -101,39 +102,34 @@ export const users = { .toLowerCase(); // Attempt to resolve the user's ConnectWise identifier by email - const cwIdentifier = await prisma.cwMember - .findFirst({ where: { officeEmail: resolvedEmail } }) - .then((m) => m?.identifier ?? null) - .catch(() => null); + const cwIdentifier = await findCwIdentifierByEmail(resolvedEmail).catch(() => null); - const existingUser = await prisma.user.findUnique({ + const existingUser = await prisma.user.findFirst({ where: { email: resolvedEmail }, select: { id: true }, }); - const newUser = await prisma.user.upsert({ - where: { email: resolvedEmail }, - create: { - userId: msData.id, - email: resolvedEmail, - firstName: msData.givenName ?? null, - lastName: msData.surname ?? null, - login: resolvedLogin, - cwIdentifier, - token, - }, - update: { - userId: msData.id, - firstName: msData.givenName ?? null, - lastName: msData.surname ?? null, - login: resolvedLogin, - cwIdentifier, - token, - }, - include: { roles: true }, - }); + const userData = { + userId: msData.id, + firstName: msData.givenName ?? null, + lastName: msData.surname ?? null, + login: resolvedLogin, + cwIdentifier, + token, + }; - let controller = new UserController(newUser); + const newUser = existingUser + ? await prisma.user.update({ + where: { email: resolvedEmail }, + data: userData, + include: { roles: true }, + }) + : await prisma.user.create({ + data: { email: resolvedEmail, ...userData }, + include: { roles: true }, + }); + + const controller = new UserController(newUser); if (!existingUser) { events.emit("user:created", controller); } diff --git a/api/src/modules/algorithms/computeCacheTTL.ts b/api/src/modules/algorithms/computeCacheTTL.ts new file mode 100644 index 0000000..2113bfd --- /dev/null +++ b/api/src/modules/algorithms/computeCacheTTL.ts @@ -0,0 +1,70 @@ +/** + * computeCacheTTL + * + * Computes the Redis TTL (in milliseconds) for an opportunity record based on + * its activity dates and closed state. + * + * Rules (highest priority first): + * 1a. Closed > 30 days ago → null (do not cache) + * 1b. Closed ≤ 30 days ago → TTL_LOW_ACTIVITY (15 min) + * 2. High activity (≤ 5 days) → TTL_HIGH_ACTIVITY (30 s) + * 3. Moderate activity (6–14 days) → TTL_MODERATE_ACTIVITY (60 s) + * 4. Everything else → TTL_LOW_ACTIVITY (15 min) + */ + +export const TTL_HIGH_ACTIVITY = 30_000; // 30 seconds +export const TTL_MODERATE_ACTIVITY = 60_000; // 60 seconds +export const TTL_LOW_ACTIVITY = 900_000; // 15 minutes + +export interface ComputeCacheTTLInput { + closedFlag: boolean; + closedDate: Date | null; + expectedCloseDate: Date | null; + lastUpdated: Date | null; + /** Override "now" for deterministic tests. Defaults to `new Date()`. */ + now?: Date; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Returns the absolute age in milliseconds between `date` and `now`. */ +function ageMs(date: Date, now: Date): number { + return Math.abs(now.getTime() - date.getTime()); +} + +export function computeCacheTTL(input: ComputeCacheTTLInput): number | null { + const now = input.now ?? new Date(); + + // Rule 1 — closed opportunities + if (input.closedFlag) { + if (!input.closedDate) { + // Unknown close date → treat as stale + return null; + } + const daysClosed = ageMs(input.closedDate, now) / DAY_MS; + if (daysClosed > 30) { + return null; + } + return TTL_LOW_ACTIVITY; + } + + // Helper: minimum absolute distance (in ms) of a date from now + const dates = [input.lastUpdated, input.expectedCloseDate].filter( + (d): d is Date => d !== null + ); + + if (dates.length === 0) { + return TTL_LOW_ACTIVITY; + } + + const minDeltaMs = Math.min(...dates.map((d) => ageMs(d, now))); + const minDeltaDays = minDeltaMs / DAY_MS; + + if (minDeltaDays <= 5) { + return TTL_HIGH_ACTIVITY; + } + if (minDeltaDays <= 14) { + return TTL_MODERATE_ACTIVITY; + } + return TTL_LOW_ACTIVITY; +} diff --git a/api/src/modules/algorithms/computeProductsCacheTTL.ts b/api/src/modules/algorithms/computeProductsCacheTTL.ts new file mode 100644 index 0000000..9efb62f --- /dev/null +++ b/api/src/modules/algorithms/computeProductsCacheTTL.ts @@ -0,0 +1,71 @@ +/** + * computeProductsCacheTTL + * + * Computes the Redis TTL (in milliseconds) for an opportunity's products + * cache entry. + * + * Rules (highest priority first): + * 1. Won / Lost status → null (do not cache — products are final) + * 2. Opp not cacheable (closed > 30 days) → null + * 3. Updated within 3 days → PRODUCTS_TTL_HOT (45 s) + * 4. Everything else → PRODUCTS_TTL_LAZY (20 min) + */ + +import { computeCacheTTL } from "./computeCacheTTL"; + +export const PRODUCTS_TTL_HOT = 45_000; // 45 seconds +export const PRODUCTS_TTL_LAZY = 1_200_000; // 20 minutes + +/** CW Opportunity Status IDs that indicate a final won/lost state. */ +export const WON_LOST_STATUS_IDS = new Set([ + 29, // Won + 53, // Lost + 59, // Canceled +]); + +export interface ComputeProductsCacheTTLInput { + statusCwId: number | null; + closedFlag: boolean; + closedDate: Date | null; + expectedCloseDate: Date | null; + lastUpdated: Date | null; + /** Override "now" for deterministic tests. Defaults to `new Date()`. */ + now?: Date; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +export function computeProductsCacheTTL( + input: ComputeProductsCacheTTLInput +): number | null { + const now = input.now ?? new Date(); + + // Rule 1 — Won/Lost status means products are final + if (input.statusCwId !== null && WON_LOST_STATUS_IDS.has(input.statusCwId)) { + return null; + } + + // Rule 2 — If the parent opportunity wouldn't be cached, skip products too + const oppTTL = computeCacheTTL({ + closedFlag: input.closedFlag, + closedDate: input.closedDate, + expectedCloseDate: input.expectedCloseDate, + lastUpdated: input.lastUpdated, + now, + }); + if (oppTTL === null) { + return null; + } + + // Rule 3 — Recently updated → hot cache + if (input.lastUpdated !== null) { + const ageDays = + Math.abs(now.getTime() - input.lastUpdated.getTime()) / DAY_MS; + if (ageDays <= 3) { + return PRODUCTS_TTL_HOT; + } + } + + // Rule 4 — Everything else → lazy cache + return PRODUCTS_TTL_LAZY; +} diff --git a/api/src/modules/algorithms/computeSubResourceCacheTTL.ts b/api/src/modules/algorithms/computeSubResourceCacheTTL.ts new file mode 100644 index 0000000..9bf36cd --- /dev/null +++ b/api/src/modules/algorithms/computeSubResourceCacheTTL.ts @@ -0,0 +1,57 @@ +/** + * computeSubResourceCacheTTL + * + * Computes the Redis TTL (in milliseconds) for opportunity sub-resources + * (notes, contacts, activities, etc.) that share the parent opportunity's + * activity window. + * + * Rules are identical to the main opportunity TTL but use different + * exported constant names so callers can distinguish them. + */ + +export const SUB_TTL_HIGH_ACTIVITY = 60_000; // 60 seconds +export const SUB_TTL_MODERATE_ACTIVITY = 120_000; // 2 minutes +export const SUB_TTL_LOW_ACTIVITY = 300_000; // 5 minutes + +export interface ComputeSubResourceCacheTTLInput { + closedFlag: boolean; + closedDate: Date | null; + expectedCloseDate: Date | null; + lastUpdated: Date | null; + now?: Date; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function ageMs(date: Date, now: Date): number { + return Math.abs(now.getTime() - date.getTime()); +} + +export function computeSubResourceCacheTTL( + input: ComputeSubResourceCacheTTLInput +): number | null { + const now = input.now ?? new Date(); + + // Rule 1a — closed > 30 days → no cache + if (input.closedFlag) { + if (!input.closedDate) return null; + const daysClosed = ageMs(input.closedDate, now) / DAY_MS; + if (daysClosed > 30) return null; + return SUB_TTL_LOW_ACTIVITY; + } + + const dates = [input.lastUpdated, input.expectedCloseDate].filter( + (d): d is Date => d !== null + ); + + if (dates.length === 0) { + return SUB_TTL_LOW_ACTIVITY; + } + + const minDeltaDays = + Math.min(...dates.map((d) => ageMs(d, now))) / DAY_MS; + + if (minDeltaDays <= 5) return SUB_TTL_HIGH_ACTIVITY; + if (minDeltaDays <= 14) return SUB_TTL_MODERATE_ACTIVITY; + return SUB_TTL_LOW_ACTIVITY; +} diff --git a/api/src/modules/cache/opportunityCache.ts b/api/src/modules/cache/opportunityCache.ts new file mode 100644 index 0000000..415e08e --- /dev/null +++ b/api/src/modules/cache/opportunityCache.ts @@ -0,0 +1,146 @@ +/** + * opportunityCache + * + * Redis-backed cache helpers for opportunity sub-resources: + * activities, notes, contacts, products, company CW data, site data. + */ + +import { redis } from "../../constants"; +import { activityCw } from "../cw-utils/activities/activities"; +import { opportunityCw } from "../cw-utils/opportunities/opportunities"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Key helpers +// ═══════════════════════════════════════════════════════════════════════════ + +export const activityCacheKey = (cwOppId: number) => + `opp:activities:${cwOppId}`; + +export const companyCwCacheKey = (cwCompanyId: number) => + `opp:company-cw:${cwCompanyId}`; + +export const notesCacheKey = (cwOppId: number) => `opp:notes:${cwOppId}`; + +export const contactsCacheKey = (cwOppId: number) => + `opp:contacts:${cwOppId}`; + +export const productsCacheKey = (cwOppId: number) => + `opp:products:${cwOppId}`; + +export const siteCacheKey = (cwCompanyId: number, siteId: number) => + `opp:site:${cwCompanyId}:${siteId}`; + +export const oppCwDataCacheKey = (cwOppId: number) => + `opp:cw-data:${cwOppId}`; + +// ═══════════════════════════════════════════════════════════════════════════ +// Generic helpers +// ═══════════════════════════════════════════════════════════════════════════ + +async function getJson(key: string): Promise { + const raw = await redis.get(key); + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function isAxios404(err: unknown): boolean { + const e = err as any; + return e?.isAxiosError === true && e?.response?.status === 404; +} + +function isTransient(err: unknown): boolean { + const e = err as any; + return ( + e?.isAxiosError === true && + (e?.code === "ECONNABORTED" || + e?.code === "ECONNRESET" || + e?.code === "ETIMEDOUT") + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Read helpers +// ═══════════════════════════════════════════════════════════════════════════ + +export async function getCachedActivities( + cwOppId: number +): Promise { + return getJson(activityCacheKey(cwOppId)); +} + +export async function getCachedCompanyCwData( + cwCompanyId: number +): Promise { + return getJson(companyCwCacheKey(cwCompanyId)); +} + +export async function getCachedNotes(cwOppId: number): Promise { + return getJson(notesCacheKey(cwOppId)); +} + +export async function getCachedContacts( + cwOppId: number +): Promise { + return getJson(contactsCacheKey(cwOppId)); +} + +export async function getCachedProducts(cwOppId: number): Promise { + return getJson(productsCacheKey(cwOppId)); +} + +export async function getCachedSite( + cwCompanyId: number, + siteId: number +): Promise { + return getJson(siteCacheKey(cwCompanyId, siteId)); +} + +export async function getCachedOppCwData(cwOppId: number): Promise { + return getJson(oppCwDataCacheKey(cwOppId)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Write helpers +// ═══════════════════════════════════════════════════════════════════════════ + +export async function fetchAndCacheActivities( + cwOppId: number, + ttlMs: number +): Promise { + try { + const activities = await activityCw.fetchByOpportunityDirect(cwOppId); + await redis.set( + activityCacheKey(cwOppId), + JSON.stringify(activities), + "PX", + ttlMs + ); + return activities; + } catch (err) { + if (isAxios404(err) || isTransient(err)) return []; + throw err; + } +} + +export async function fetchAndCacheNotes( + cwOppId: number, + ttlMs: number +): Promise { + try { + const notes = await opportunityCw.fetchNotes(cwOppId); + await redis.set( + notesCacheKey(cwOppId), + JSON.stringify(notes), + "PX", + ttlMs + ); + return notes; + } catch (err) { + if (isAxios404(err) || isTransient(err)) return []; + throw err; + } +} diff --git a/api/src/modules/cw-utils/fetchCompany.ts b/api/src/modules/cw-utils/fetchCompany.ts new file mode 100644 index 0000000..41464dc --- /dev/null +++ b/api/src/modules/cw-utils/fetchCompany.ts @@ -0,0 +1,31 @@ +/** + * fetchCwCompany + * + * Helpers for fetching ConnectWise company data via the CW REST API. + */ + +import { connectWiseApi } from "../../constants"; + +export interface CWCompany { + id: number; + identifier: string; + name: string; + [key: string]: any; +} + +/** + * Fetch a CW company by its numeric CW company ID. + * Returns null when not found or on API error. + */ +export async function fetchCwCompanyById( + cwCompanyId: number +): Promise { + try { + const response = await connectWiseApi.get( + `/company/companies/${cwCompanyId}` + ); + return response.data ?? null; + } catch { + return null; + } +} diff --git a/api/src/modules/cw-utils/members/fetchAllMembers.ts b/api/src/modules/cw-utils/members/fetchAllMembers.ts new file mode 100644 index 0000000..8acd11f --- /dev/null +++ b/api/src/modules/cw-utils/members/fetchAllMembers.ts @@ -0,0 +1,48 @@ +/** + * fetchAllMembers + * + * Utilities for fetching ConnectWise member records and resolving + * CW identifiers from email addresses. + */ + +import { connectWiseApi, prisma } from "../../../constants"; + +export interface CWMember { + id: number; + identifier: string; + firstName: string; + lastName: string; + officeEmail?: string | null; + inactiveFlag: boolean; + _info?: Record; +} + +/** + * Fetch all active members from ConnectWise. + */ +export async function fetchAllCwMembers(): Promise { + const response = await connectWiseApi.get( + "/system/members?pageSize=1000&conditions=inactiveFlag=false" + ); + return response.data ?? []; +} + +/** + * Resolve a ConnectWise member identifier given an email address. + * + * First checks the local database, then falls back to CW API. + * Returns null when no match is found. + */ +export async function findCwIdentifierByEmail( + email: string +): Promise { + const normalised = email.trim().toLowerCase(); + + const local = await prisma.cwMember + .findFirst({ where: { officeEmail: normalised } }) + .catch(() => null); + + if (local) return local.identifier; + + return null; +} diff --git a/api/src/modules/cw-utils/members/memberCache.ts b/api/src/modules/cw-utils/members/memberCache.ts new file mode 100644 index 0000000..bbc56f8 --- /dev/null +++ b/api/src/modules/cw-utils/members/memberCache.ts @@ -0,0 +1,68 @@ +/** + * memberCache + * + * In-process Collection cache for ConnectWise member records. + * Used to avoid repeated DB lookups when resolving member names. + */ + +import { Collection } from "@discordjs/collection"; +import { prisma } from "../../../constants"; +import type { CWMember } from "./fetchAllMembers"; + +let _cache: Collection = new Collection(); + +/** + * Replace the entire member cache with a new collection. + */ +export async function setMemberCache( + members: Collection +): Promise { + _cache = members; +} + +/** + * Return the current member cache collection. + */ +export async function getMemberCache(): Promise> { + return _cache; +} + +/** + * Resolve a display name from a member identifier. + * + * Returns " " when the member is found in the cache, + * or the raw identifier string when not found / name parts are empty. + */ +export function resolveMemberName(identifier: string): string { + const member = _cache.get(identifier); + if (!member) return identifier; + + const full = `${member.firstName} ${member.lastName}`.trim(); + return full || identifier; +} + +/** + * Resolve a member identifier to a structured object containing + * the member's name, CW member ID, and local user ID (if any). + */ +export async function resolveMember(identifier: string): Promise<{ + identifier: string; + name: string; + cwMemberId: number | null; + id: string | null; +}> { + const member = _cache.get(identifier); + const name = resolveMemberName(identifier); + const cwMemberId = member?.id ?? null; + + const localUser = await prisma.user + .findFirst({ where: { cwIdentifier: identifier }, select: { id: true } }) + .catch(() => null); + + return { + identifier, + name, + cwMemberId, + id: localUser?.id ?? null, + }; +} diff --git a/api/src/modules/cw-utils/sites/companySites.ts b/api/src/modules/cw-utils/sites/companySites.ts new file mode 100644 index 0000000..e162ce6 --- /dev/null +++ b/api/src/modules/cw-utils/sites/companySites.ts @@ -0,0 +1,103 @@ +/** + * companySites + * + * Types and helpers for ConnectWise company sites (addresses). + */ + +import { connectWiseApi } from "../../../constants"; + +export interface CWCompanySite { + id: number; + name: string; + addressLine1?: string | null; + addressLine2?: string | null; + city?: string | null; + stateReference?: { id: number; identifier: string; name: string } | null; + zip?: string | null; + country?: { id: number; name: string } | null; + phoneNumber?: string | null; + faxNumber?: string | null; + taxCodeId?: number | null; + expenseReimbursement?: number | null; + primaryAddressFlag?: boolean; + defaultShippingFlag?: boolean; + defaultBillingFlag?: boolean; + defaultMailingFlag?: boolean; + mobileGuid?: string | null; + calendar?: any | null; + timeZone?: any | null; + company?: { id: number; identifier: string; name: string } | null; + _info?: Record; +} + +export interface SerializedCwSite { + id: number; + name: string; + address: { + line1: string | null; + line2: string | null; + city: string | null; + state: string | null; + zip: string | null; + country: string; + }; + phoneNumber: string | null; + faxNumber: string | null; + primaryAddressFlag: boolean; + defaultShippingFlag: boolean; + defaultBillingFlag: boolean; + defaultMailingFlag: boolean; +} + +/** + * Serialize a raw ConnectWise company site into a clean API shape. + */ +export function serializeCwSite(site: CWCompanySite): SerializedCwSite { + return { + id: site.id, + name: site.name, + address: { + line1: site.addressLine1 ?? null, + line2: site.addressLine2 ?? null, + city: site.city ?? null, + state: site.stateReference?.name ?? null, + zip: site.zip ?? null, + country: site.country?.name ?? "United States", + }, + phoneNumber: site.phoneNumber || null, + faxNumber: site.faxNumber || null, + primaryAddressFlag: site.primaryAddressFlag ?? false, + defaultShippingFlag: site.defaultShippingFlag ?? false, + defaultBillingFlag: site.defaultBillingFlag ?? false, + defaultMailingFlag: site.defaultMailingFlag ?? false, + }; +} + +/** + * Fetch all sites for a given CW company ID. + */ +export async function fetchCompanySites( + cwCompanyId: number +): Promise { + const response = await connectWiseApi.get( + `/company/companies/${cwCompanyId}/sites?pageSize=1000` + ); + return response.data ?? []; +} + +/** + * Fetch a single site by CW company ID and site ID. + */ +export async function fetchCompanySite( + cwCompanyId: number, + siteId: number +): Promise { + try { + const response = await connectWiseApi.get( + `/company/companies/${cwCompanyId}/sites/${siteId}` + ); + return response.data ?? null; + } catch { + return null; + } +} diff --git a/api/tests/setup.ts b/api/tests/setup.ts index 8d95629..9d258c4 100644 --- a/api/tests/setup.ts +++ b/api/tests/setup.ts @@ -237,7 +237,8 @@ export function buildMockUser(overrides: Record = {}) { return { id: "user-1", userId: "ms-uid-1", - name: "Test User", + firstName: "Test", + lastName: "User", login: "test@example.com", email: "test@example.com", emailVerified: null, @@ -270,6 +271,7 @@ export function buildMockCompany(overrides: Record = {}) { return { id: 123, uid: "company-1", + identifier: "TestCo", name: "Test Company", phone: "555-1234", website: "https://test.com", @@ -334,7 +336,7 @@ export function buildMockCredential(overrides: Record = {}) { name: "Test Credential", notes: null, typeId: ctype.id, - companyId: company.id, + companyId: company.uid, subCredentialOfId: null, fields: { username: "admin" }, type: ctype, @@ -379,6 +381,7 @@ export function buildMockOpportunity(overrides: Record = {}) { notes: "Some notes", typeId: 1, type: { id: 1, name: "New Business" }, + stage: { id: 2, name: "Proposal" }, stageName: "Proposal", stageCwId: 2, statusId: 3, @@ -398,7 +401,7 @@ export function buildMockOpportunity(overrides: Record = {}) { secondarySalesRepCwId: null, companyCwId: 123, companyName: "Test Company", - companyId: "company-1", + companyId: 123, contactCwId: 200, contactName: "Jane Doe", siteCwId: 300, @@ -517,19 +520,19 @@ export function buildMockGeneratedQuote(overrides: Record = {}) { /** Build a minimal Prisma-shaped CatalogItem row. */ export function buildMockCatalogItem(overrides: Record = {}) { return { - id: "cat-1", - cwCatalogId: 500, + id: 500, + uid: "cat-1", identifier: "USW-Pro-24", name: "UniFi Switch Pro 24", description: "24-port managed switch", customerDescription: "Enterprise switch", internalNotes: null, - category: "Technology", - categoryCwId: 18, - subcategory: "Network-Switch", - subcategoryCwId: 112, - manufacturer: "Ubiquiti", - manufactureCwId: 248, + subcategory: { + id: 112, + name: "Network-Switch", + category: { id: 18, name: "Technology" }, + }, + manufacturer: { id: 248, name: "Ubiquiti" }, partNumber: "USW-Pro-24", vendorName: "Ubiquiti Inc", vendorSku: "USW-Pro-24",