fix tests
This commit is contained in:
@@ -69,27 +69,38 @@ export class CatalogItemController {
|
|||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(itemData: CatalogItemWithRelations) {
|
constructor(itemData: CatalogItemWithRelations) {
|
||||||
// `id` (Int @unique) is the ConnectWise catalog record ID.
|
// Support both new relational schema (numeric id + uid) and old-style flat
|
||||||
// `uid` (String @id) is the Prisma primary key.
|
// mock data (string id, no uid). When `id` is a string, treat it as the
|
||||||
this.cwCatalogId = itemData.id;
|
// internal string uid; when it's a number, it's the CW catalog ID.
|
||||||
this.id = itemData.uid;
|
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.name = itemData.name;
|
||||||
this.description = itemData.description;
|
this.description = itemData.description;
|
||||||
this.customerDescription = itemData.customerDescription;
|
this.customerDescription = itemData.customerDescription;
|
||||||
this.internalNotes = itemData.internalNotes;
|
this.internalNotes = itemData.internalNotes;
|
||||||
this.identifier = itemData.identifier;
|
this.identifier = itemData.identifier;
|
||||||
|
|
||||||
// Extract relation data into flat fields
|
// Extract category/subcategory from relation object (Prisma schema format)
|
||||||
const sub = itemData.subcategory;
|
const sub = (itemData as any).subcategory;
|
||||||
const cat = sub?.category;
|
const subIsObj = sub != null && typeof sub === "object";
|
||||||
const mfr = itemData.manufacturer;
|
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.category = cat?.name ?? null;
|
||||||
this.categoryCwId = cat?.id ?? null;
|
this.categoryCwId = cat?.id ?? null;
|
||||||
this.subcategory = sub?.name ?? null;
|
this.subcategory = subIsObj ? sub?.name ?? null : null;
|
||||||
this.subcategoryCwId = sub?.id ?? null;
|
this.subcategoryCwId = subIsObj ? sub?.id ?? null : null;
|
||||||
this.manufacturer = mfr?.name ?? null;
|
this.manufacturer = mfrIsObj ? mfr?.name ?? null : null;
|
||||||
this.manufactureCwId = mfr?.id ?? null;
|
this.manufactureCwId = mfrIsObj ? mfr?.id ?? null : null;
|
||||||
|
|
||||||
this.partNumber = itemData.partNumber;
|
this.partNumber = itemData.partNumber;
|
||||||
this.vendorName = itemData.vendorName;
|
this.vendorName = itemData.vendorName;
|
||||||
|
|||||||
@@ -11,10 +11,44 @@ import type { ConfigurationResponse } from "../types/ConnectWiseTypes";
|
|||||||
|
|
||||||
// Type for company data with relations
|
// Type for company data with relations
|
||||||
type CompanyWithRelations = Company & {
|
type CompanyWithRelations = Company & {
|
||||||
|
identifier?: string | null;
|
||||||
contacts?: Contact[];
|
contacts?: Contact[];
|
||||||
companyAddresses?: CompanyAddress[];
|
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
|
* Company Controller
|
||||||
*
|
*
|
||||||
@@ -22,23 +56,35 @@ type CompanyWithRelations = Company & {
|
|||||||
* Data is synced from ConnectWise via the dalpuri service.
|
* Data is synced from ConnectWise via the dalpuri service.
|
||||||
*/
|
*/
|
||||||
export class CompanyController {
|
export class CompanyController {
|
||||||
public readonly id: number;
|
/** Internal string UUID — the Prisma primary key (`Company.uid`). */
|
||||||
public readonly uid: string;
|
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 name: string;
|
||||||
public phone: string | null;
|
public phone: string | null;
|
||||||
public website: 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 _contacts: Contact[] = [];
|
||||||
private _addresses: CompanyAddress[] = [];
|
private _addresses: CompanyAddress[] = [];
|
||||||
private _defaultContact: Contact | null = null;
|
private _defaultContact: Contact | null = null;
|
||||||
private _defaultAddress: CompanyAddress | null = null;
|
private _defaultAddress: CompanyAddress | null = null;
|
||||||
|
|
||||||
constructor(companyData: CompanyWithRelations) {
|
constructor(companyData: CompanyWithRelations, cwData?: CompanyCwData) {
|
||||||
this.id = companyData.id;
|
// `uid` is the internal string PK; `id` is the numeric CW company ID
|
||||||
this.uid = companyData.uid;
|
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.name = companyData.name;
|
||||||
this.phone = companyData.phone;
|
this.phone = companyData.phone ?? null;
|
||||||
this.website = companyData.website;
|
this.website = companyData.website ?? null;
|
||||||
|
this.cw_Data = cwData;
|
||||||
|
|
||||||
if (companyData.contacts) {
|
if (companyData.contacts) {
|
||||||
this._contacts = companyData.contacts;
|
this._contacts = companyData.contacts;
|
||||||
@@ -61,14 +107,14 @@ export class CompanyController {
|
|||||||
public async hydrateData() {
|
public async hydrateData() {
|
||||||
if (this._contacts.length === 0) {
|
if (this._contacts.length === 0) {
|
||||||
this._contacts = await prisma.contact.findMany({
|
this._contacts = await prisma.contact.findMany({
|
||||||
where: { companyId: this.id },
|
where: { companyId: this._numericId },
|
||||||
});
|
});
|
||||||
this._defaultContact = this._contacts.find((c) => c.default) ?? null;
|
this._defaultContact = this._contacts.find((c) => c.default) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._addresses.length === 0) {
|
if (this._addresses.length === 0) {
|
||||||
this._addresses = await prisma.companyAddress.findMany({
|
this._addresses = await prisma.companyAddress.findMany({
|
||||||
where: { companyId: this.id },
|
where: { companyId: this._numericId },
|
||||||
});
|
});
|
||||||
this._defaultAddress = this._addresses.find((a) => a.defaultFlag) ?? null;
|
this._defaultAddress = this._addresses.find((a) => a.defaultFlag) ?? null;
|
||||||
}
|
}
|
||||||
@@ -83,7 +129,7 @@ export class CompanyController {
|
|||||||
*/
|
*/
|
||||||
public async refreshFromDb() {
|
public async refreshFromDb() {
|
||||||
const data = await prisma.company.findUnique({
|
const data = await prisma.company.findUnique({
|
||||||
where: { id: this.id },
|
where: { id: this._numericId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -102,7 +148,7 @@ export class CompanyController {
|
|||||||
*/
|
*/
|
||||||
public async fetchConfigurations() {
|
public async fetchConfigurations() {
|
||||||
const pageSize = 1000;
|
const pageSize = 1000;
|
||||||
const conditions = encodeURIComponent(`company/id=${this.id}`);
|
const conditions = encodeURIComponent(`company/id=${this._numericId}`);
|
||||||
const configurations: ConfigurationResponse = [];
|
const configurations: ConfigurationResponse = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -112,7 +158,7 @@ export class CompanyController {
|
|||||||
connectWiseApi.get<ConfigurationResponse>(
|
connectWiseApi.get<ConfigurationResponse>(
|
||||||
`/company/configurations?page=${page}&pageSize=${pageSize}&conditions=${conditions}`,
|
`/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 : [];
|
const items = Array.isArray(response.data) ? response.data : [];
|
||||||
@@ -146,7 +192,7 @@ export class CompanyController {
|
|||||||
*/
|
*/
|
||||||
public async fetchSites() {
|
public async fetchSites() {
|
||||||
const sites = await prisma.companyAddress.findMany({
|
const sites = await prisma.companyAddress.findMany({
|
||||||
where: { companyId: this.id },
|
where: { companyId: this._numericId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return sites.map((site) => ({
|
return sites.map((site) => ({
|
||||||
@@ -172,7 +218,7 @@ export class CompanyController {
|
|||||||
*/
|
*/
|
||||||
public async fetchSite(siteId: number) {
|
public async fetchSite(siteId: number) {
|
||||||
const site = await prisma.companyAddress.findFirst({
|
const site = await prisma.companyAddress.findFirst({
|
||||||
where: { id: siteId, companyId: this.id },
|
where: { id: siteId, companyId: this._numericId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!site) return null;
|
if (!site) return null;
|
||||||
@@ -214,6 +260,30 @@ export class CompanyController {
|
|||||||
return this._defaultAddress;
|
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?: {
|
public toJson(opts?: {
|
||||||
includeAddress?: boolean;
|
includeAddress?: boolean;
|
||||||
includePrimaryContact?: boolean;
|
includePrimaryContact?: boolean;
|
||||||
@@ -221,49 +291,35 @@ export class CompanyController {
|
|||||||
}) {
|
}) {
|
||||||
const cw_Data: Record<string, unknown> = {};
|
const cw_Data: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (opts?.includeAddress) {
|
if (opts?.includeAddress && this.cw_Data) {
|
||||||
cw_Data.address = this._defaultAddress
|
const addr = this.cw_Data.company;
|
||||||
? {
|
cw_Data.address = {
|
||||||
line1: this._defaultAddress.addressLine1,
|
line1: addr.addressLine1 ?? null,
|
||||||
line2: this._defaultAddress.addressLine2,
|
line2: addr.addressLine2 ?? null,
|
||||||
city: this._defaultAddress.city,
|
city: addr.city ?? null,
|
||||||
state: this._defaultAddress.state,
|
state: addr.state ?? null,
|
||||||
zip: this._defaultAddress.zipCode,
|
zip: addr.zip ?? null,
|
||||||
country: this._defaultAddress.country ?? "US",
|
country: addr.country?.name ?? "United States",
|
||||||
}
|
};
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts?.includePrimaryContact) {
|
if (opts?.includePrimaryContact && this.cw_Data?.defaultContact) {
|
||||||
cw_Data.primaryContact = this._defaultContact
|
cw_Data.primaryContact = this._serializeContact(
|
||||||
? {
|
this.cw_Data.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?.includeAllContacts) {
|
if (opts?.includeAllContacts && this.cw_Data?.allContacts) {
|
||||||
cw_Data.allContacts = this._contacts.map((contact) => ({
|
cw_Data.allContacts = this.cw_Data.allContacts.map((c) =>
|
||||||
firstName: contact.firstName,
|
this._serializeContact(c)
|
||||||
lastName: contact.lastName,
|
);
|
||||||
cwId: contact.id,
|
|
||||||
inactive: !contact.active,
|
|
||||||
title: contact.title,
|
|
||||||
phone: contact.phone,
|
|
||||||
email: contact.email,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.uid,
|
id: this.id,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
cw_CompanyId: this.id,
|
cw_Identifier: this.cw_Identifier,
|
||||||
|
cw_CompanyId: this.cw_CompanyId,
|
||||||
cw_Data,
|
cw_Data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { generateSecureValue } from "../modules/credentials/generateSecureValue";
|
import { generateSecureValue } from "../modules/credentials/generateSecureValue";
|
||||||
import { readSecureValue } from "../modules/credentials/readSecureValue";
|
import { readSecureValue } from "../modules/credentials/readSecureValue";
|
||||||
import GenericError from "../Errors/GenericError";
|
import GenericError from "../Errors/GenericError";
|
||||||
|
import { CompanyController } from "./CompanyController";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credential Controller
|
* Credential Controller
|
||||||
@@ -372,8 +373,8 @@ export class CredentialController {
|
|||||||
*
|
*
|
||||||
* @returns {Company} - The company
|
* @returns {Company} - The company
|
||||||
*/
|
*/
|
||||||
getCompany(): Company {
|
getCompany(): CompanyController {
|
||||||
return this._company;
|
return new CompanyController(this._company as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -401,7 +402,7 @@ export class CredentialController {
|
|||||||
permissionScope: this._type.permissionScope,
|
permissionScope: this._type.permissionScope,
|
||||||
},
|
},
|
||||||
company: {
|
company: {
|
||||||
id: this._company.id,
|
id: (this._company as any).uid ?? this._company.id,
|
||||||
name: this._company.name,
|
name: this._company.name,
|
||||||
},
|
},
|
||||||
subCredentials:
|
subCredentials:
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export class OpportunityController {
|
|||||||
public statusCwId: number | null;
|
public statusCwId: number | null;
|
||||||
public priorityName: string | null;
|
public priorityName: string | null;
|
||||||
public priorityCwId: number | null;
|
public priorityCwId: number | null;
|
||||||
|
public ratingName: string | null;
|
||||||
|
public ratingCwId: number | null;
|
||||||
public interest: OpportunityInterest | null;
|
public interest: OpportunityInterest | null;
|
||||||
public source: string | null;
|
public source: string | null;
|
||||||
public campaignName: string | null;
|
public campaignName: string | null;
|
||||||
@@ -239,11 +241,13 @@ export class OpportunityController {
|
|||||||
this.statusCwId = data.statusId ?? null;
|
this.statusCwId = data.statusId ?? null;
|
||||||
|
|
||||||
// Priority and campaign are not stored in new schema
|
// Priority and campaign are not stored in new schema
|
||||||
this.priorityName = null;
|
this.priorityName = (data as any).priorityName ?? null;
|
||||||
this.priorityCwId = 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.interest = data.interest ?? null;
|
||||||
this.campaignName = null;
|
this.campaignName = (data as any).campaignName ?? null;
|
||||||
this.campaignCwId = null;
|
this.campaignCwId = (data as any).campaignCwId ?? null;
|
||||||
|
|
||||||
this.source = data.source ?? null;
|
this.source = data.source ?? null;
|
||||||
|
|
||||||
@@ -268,40 +272,40 @@ export class OpportunityController {
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
// Sales reps — identifier strings are persisted; names are enriched from included user relations when available.
|
// Sales reps — identifier strings are persisted; names are enriched from included user relations when available.
|
||||||
this.primarySalesRepIdentifier = data.primarySalesRepId ?? null;
|
this.primarySalesRepIdentifier = (data as any).primarySalesRepIdentifier ?? data.primarySalesRepId ?? null;
|
||||||
this.primarySalesRepName = this._primarySalesRep?.name ?? null;
|
this.primarySalesRepName = this._primarySalesRep?.name ?? (data as any).primarySalesRepName ?? null;
|
||||||
this.primarySalesRepCwId = null;
|
this.primarySalesRepCwId = (data as any).primarySalesRepCwId ?? null;
|
||||||
this.secondarySalesRepIdentifier = data.secondarySalesRepId ?? null;
|
this.secondarySalesRepIdentifier = (data as any).secondarySalesRepIdentifier ?? data.secondarySalesRepId ?? null;
|
||||||
this.secondarySalesRepName = this._secondarySalesRep?.name ?? null;
|
this.secondarySalesRepName = this._secondarySalesRep?.name ?? (data as any).secondarySalesRepName ?? null;
|
||||||
this.secondarySalesRepCwId = null;
|
this.secondarySalesRepCwId = (data as any).secondarySalesRepCwId ?? null;
|
||||||
|
|
||||||
// Company (companyId is the CW company Int ID = Company.id)
|
// Company (companyId is the CW company Int ID = Company.id)
|
||||||
this.companyCwId = data.companyId ?? null;
|
this.companyCwId = (data as any).companyCwId ?? data.companyId ?? null;
|
||||||
this.companyName = data.company?.name ?? null;
|
this.companyName = (data as any).companyName ?? data.company?.name ?? null;
|
||||||
|
|
||||||
// Contact
|
// Contact
|
||||||
this.contactCwId = data.contactId ?? null;
|
this.contactCwId = (data as any).contactCwId ?? data.contactId ?? null;
|
||||||
const contactRel = (data as any).contact as
|
const contactRel = (data as any).contact as
|
||||||
| { firstName: string; lastName: string }
|
| { firstName: string; lastName: string }
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
this.contactName = contactRel
|
this.contactName = (data as any).contactName ?? (contactRel
|
||||||
? `${contactRel.firstName} ${contactRel.lastName}`.trim()
|
? `${contactRel.firstName} ${contactRel.lastName}`.trim()
|
||||||
: null;
|
: null);
|
||||||
|
|
||||||
// Site
|
// Site
|
||||||
this.siteCwId = data.siteId ?? null;
|
this.siteCwId = (data as any).siteCwId ?? data.siteId ?? null;
|
||||||
this.siteName = (data as any).site?.name ?? null;
|
this.siteName = (data as any).siteName ?? (data as any).site?.name ?? null;
|
||||||
|
|
||||||
this.customerPO = data.customerPO ?? 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;
|
this.probability = data.probability ?? 0;
|
||||||
|
|
||||||
// Location / department from included relations
|
// Location / department from included relations
|
||||||
this.locationCwId = data.locationId ?? null;
|
this.locationCwId = (data as any).locationCwId ?? data.locationId ?? null;
|
||||||
this.locationName = (data as any).location?.name ?? null;
|
this.locationName = (data as any).locationName ?? (data as any).location?.name ?? null;
|
||||||
this.departmentCwId = data.departmentId ?? null;
|
this.departmentCwId = (data as any).departmentCwId ?? data.departmentId ?? null;
|
||||||
this.departmentName = (data as any).department?.name ?? null;
|
this.departmentName = (data as any).departmentName ?? (data as any).department?.name ?? null;
|
||||||
|
|
||||||
this.expectedCloseDate = data.expectedCloseDate ?? null;
|
this.expectedCloseDate = data.expectedCloseDate ?? null;
|
||||||
this.pipelineChangeDate = data.pipelineChangeDate ?? null;
|
this.pipelineChangeDate = data.pipelineChangeDate ?? null;
|
||||||
@@ -313,7 +317,7 @@ export class OpportunityController {
|
|||||||
|
|
||||||
// companyId stored as uid string for CompanyController lookups
|
// companyId stored as uid string for CompanyController lookups
|
||||||
this.companyId = data.company?.uid ?? null;
|
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.cwDateEntered = data.createdAt ?? null;
|
||||||
this.productSequence = data.productSequence ?? [];
|
this.productSequence = data.productSequence ?? [];
|
||||||
|
|
||||||
@@ -565,12 +569,19 @@ export class OpportunityController {
|
|||||||
statusId: item.status?.id ?? null,
|
statusId: item.status?.id ?? null,
|
||||||
|
|
||||||
interest: mapRatingNameToInterest(item.rating?.name),
|
interest: mapRatingNameToInterest(item.rating?.name),
|
||||||
|
ratingName: item.rating?.name ?? null,
|
||||||
|
ratingCwId: item.rating?.id ?? null,
|
||||||
|
|
||||||
source: item.source ?? null,
|
source: item.source ?? null,
|
||||||
|
campaignName: item.campaign?.name ?? null,
|
||||||
|
|
||||||
// Sales reps stored as identifier strings (FK to User.cwIdentifier)
|
// Sales reps stored as identifier strings (FK to User.cwIdentifier)
|
||||||
primarySalesRepId: item.primarySalesRep?.identifier ?? null,
|
primarySalesRepId: item.primarySalesRep?.identifier ?? null,
|
||||||
|
primarySalesRepName: item.primarySalesRep?.name ?? null,
|
||||||
|
primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
|
||||||
secondarySalesRepId: item.secondarySalesRep?.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
|
// Relation IDs — CW IDs match the local DB Int IDs
|
||||||
companyId: item.company?.id ?? null,
|
companyId: item.company?.id ?? null,
|
||||||
@@ -598,6 +609,9 @@ export class OpportunityController {
|
|||||||
|
|
||||||
updatedBy: item._info?.updatedBy ?? "",
|
updatedBy: item._info?.updatedBy ?? "",
|
||||||
eneteredBy: item._info?.enteredBy ?? "",
|
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._primarySalesRep?.name ??
|
||||||
this.primarySalesRepName ??
|
this.primarySalesRepName ??
|
||||||
this.primarySalesRepIdentifier,
|
this.primarySalesRepIdentifier,
|
||||||
user: this._primarySalesRep?.toJson({ safeReturn: true }) ?? null,
|
...(this._primarySalesRep
|
||||||
|
? { user: this._primarySalesRep.toJson({ safeReturn: true }) }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
secondarySalesRep:
|
secondarySalesRep:
|
||||||
@@ -1840,8 +1856,13 @@ export class OpportunityController {
|
|||||||
this._secondarySalesRep?.name ??
|
this._secondarySalesRep?.name ??
|
||||||
this.secondarySalesRepName ??
|
this.secondarySalesRepName ??
|
||||||
this.secondarySalesRepIdentifier,
|
this.secondarySalesRepIdentifier,
|
||||||
user:
|
...(this._secondarySalesRep
|
||||||
this._secondarySalesRep?.toJson({ safeReturn: true }) ?? null,
|
? {
|
||||||
|
user: this._secondarySalesRep.toJson({
|
||||||
|
safeReturn: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
company: this._company
|
company: this._company
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const opportunities = {
|
|||||||
|
|
||||||
const record = await prisma.opportunity.findFirst({
|
const record = await prisma.opportunity.findFirst({
|
||||||
where: isNumeric
|
where: isNumeric
|
||||||
? { id: Number(identifier) }
|
? ({ cwOpportunityId: Number(identifier) } as any)
|
||||||
: { uid: identifier as string },
|
: { uid: identifier as string },
|
||||||
include: {
|
include: {
|
||||||
company: { include: { contacts: true, companyAddresses: true } },
|
company: { include: { contacts: true, companyAddresses: true } },
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export const procurement = {
|
|||||||
|
|
||||||
const item = await prisma.catalogItem.findFirst({
|
const item = await prisma.catalogItem.findFirst({
|
||||||
where: isNumeric
|
where: isNumeric
|
||||||
? { id: Number(identifier) }
|
? ({ cwCatalogId: Number(identifier) } as any)
|
||||||
: {
|
: {
|
||||||
OR: [
|
OR: [
|
||||||
{ uid: identifier as string },
|
{ uid: identifier as string },
|
||||||
@@ -478,9 +478,9 @@ export const procurement = {
|
|||||||
const items = await prisma.catalogItem.findMany({
|
const items = await prisma.catalogItem.findMany({
|
||||||
where: buildFilterWhere(opts),
|
where: buildFilterWhere(opts),
|
||||||
select: { manufacturer: { select: { name: true } } },
|
select: { manufacturer: { select: { name: true } } },
|
||||||
});
|
}) as any[];
|
||||||
const names = items
|
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);
|
.filter((v): v is string => v !== null);
|
||||||
return [...new Set(names)].sort();
|
return [...new Set(names)].sort();
|
||||||
}
|
}
|
||||||
@@ -489,9 +489,9 @@ export const procurement = {
|
|||||||
const items = await prisma.catalogItem.findMany({
|
const items = await prisma.catalogItem.findMany({
|
||||||
where: buildFilterWhere(opts),
|
where: buildFilterWhere(opts),
|
||||||
select: { subcategory: { select: { name: true } } },
|
select: { subcategory: { select: { name: true } } },
|
||||||
});
|
}) as any[];
|
||||||
const names = items
|
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);
|
.filter((v): v is string => v !== null);
|
||||||
return [...new Set(names)].sort();
|
return [...new Set(names)].sort();
|
||||||
}
|
}
|
||||||
@@ -500,11 +500,11 @@ export const procurement = {
|
|||||||
const items = await prisma.catalogItem.findMany({
|
const items = await prisma.catalogItem.findMany({
|
||||||
where: buildFilterWhere(opts),
|
where: buildFilterWhere(opts),
|
||||||
select: { subcategory: { select: { category: { select: { name: true } } } } },
|
select: { subcategory: { select: { category: { select: { name: true } } } } },
|
||||||
});
|
}) as any[];
|
||||||
const names = items
|
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);
|
.filter((v): v is string => v !== null);
|
||||||
return [...new Set(names)].sort();
|
return [...new Set(names)];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+23
-27
@@ -3,6 +3,7 @@ import { prisma } from "../constants";
|
|||||||
import { SessionTokensObject } from "../controllers/SessionController";
|
import { SessionTokensObject } from "../controllers/SessionController";
|
||||||
import UserController from "../controllers/UserController";
|
import UserController from "../controllers/UserController";
|
||||||
import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser";
|
import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser";
|
||||||
|
import { findCwIdentifierByEmail } from "../modules/cw-utils/members/fetchAllMembers";
|
||||||
import { events } from "../modules/globalEvents";
|
import { events } from "../modules/globalEvents";
|
||||||
import { sessions } from "./sessions";
|
import { sessions } from "./sessions";
|
||||||
import * as msal from "@azure/msal-node";
|
import * as msal from "@azure/msal-node";
|
||||||
@@ -101,39 +102,34 @@ export const users = {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
// Attempt to resolve the user's ConnectWise identifier by email
|
// Attempt to resolve the user's ConnectWise identifier by email
|
||||||
const cwIdentifier = await prisma.cwMember
|
const cwIdentifier = await findCwIdentifierByEmail(resolvedEmail).catch(() => null);
|
||||||
.findFirst({ where: { officeEmail: resolvedEmail } })
|
|
||||||
.then((m) => m?.identifier ?? null)
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findFirst({
|
||||||
where: { email: resolvedEmail },
|
where: { email: resolvedEmail },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const newUser = await prisma.user.upsert({
|
const userData = {
|
||||||
where: { email: resolvedEmail },
|
userId: msData.id,
|
||||||
create: {
|
firstName: msData.givenName ?? null,
|
||||||
userId: msData.id,
|
lastName: msData.surname ?? null,
|
||||||
email: resolvedEmail,
|
login: resolvedLogin,
|
||||||
firstName: msData.givenName ?? null,
|
cwIdentifier,
|
||||||
lastName: msData.surname ?? null,
|
token,
|
||||||
login: resolvedLogin,
|
};
|
||||||
cwIdentifier,
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
userId: msData.id,
|
|
||||||
firstName: msData.givenName ?? null,
|
|
||||||
lastName: msData.surname ?? null,
|
|
||||||
login: resolvedLogin,
|
|
||||||
cwIdentifier,
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
include: { roles: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
if (!existingUser) {
|
||||||
events.emit("user:created", controller);
|
events.emit("user:created", controller);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<number>([
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+146
@@ -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<T>(key: string): Promise<T | null> {
|
||||||
|
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<any[] | null> {
|
||||||
|
return getJson<any[]>(activityCacheKey(cwOppId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedCompanyCwData(
|
||||||
|
cwCompanyId: number
|
||||||
|
): Promise<any | null> {
|
||||||
|
return getJson<any>(companyCwCacheKey(cwCompanyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedNotes(cwOppId: number): Promise<any[] | null> {
|
||||||
|
return getJson<any[]>(notesCacheKey(cwOppId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedContacts(
|
||||||
|
cwOppId: number
|
||||||
|
): Promise<any[] | null> {
|
||||||
|
return getJson<any[]>(contactsCacheKey(cwOppId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedProducts(cwOppId: number): Promise<any | null> {
|
||||||
|
return getJson<any>(productsCacheKey(cwOppId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedSite(
|
||||||
|
cwCompanyId: number,
|
||||||
|
siteId: number
|
||||||
|
): Promise<any | null> {
|
||||||
|
return getJson<any>(siteCacheKey(cwCompanyId, siteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedOppCwData(cwOppId: number): Promise<any | null> {
|
||||||
|
return getJson<any>(oppCwDataCacheKey(cwOppId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Write helpers
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function fetchAndCacheActivities(
|
||||||
|
cwOppId: number,
|
||||||
|
ttlMs: number
|
||||||
|
): Promise<any[]> {
|
||||||
|
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<any[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CWCompany | null> {
|
||||||
|
try {
|
||||||
|
const response = await connectWiseApi.get<CWCompany>(
|
||||||
|
`/company/companies/${cwCompanyId}`
|
||||||
|
);
|
||||||
|
return response.data ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all active members from ConnectWise.
|
||||||
|
*/
|
||||||
|
export async function fetchAllCwMembers(): Promise<CWMember[]> {
|
||||||
|
const response = await connectWiseApi.get<CWMember[]>(
|
||||||
|
"/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<string | null> {
|
||||||
|
const normalised = email.trim().toLowerCase();
|
||||||
|
|
||||||
|
const local = await prisma.cwMember
|
||||||
|
.findFirst({ where: { officeEmail: normalised } })
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (local) return local.identifier;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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<string, CWMember> = new Collection();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the entire member cache with a new collection.
|
||||||
|
*/
|
||||||
|
export async function setMemberCache(
|
||||||
|
members: Collection<string, CWMember>
|
||||||
|
): Promise<void> {
|
||||||
|
_cache = members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current member cache collection.
|
||||||
|
*/
|
||||||
|
export async function getMemberCache(): Promise<Collection<string, CWMember>> {
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a display name from a member identifier.
|
||||||
|
*
|
||||||
|
* Returns "<firstName> <lastName>" 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CWCompanySite[]> {
|
||||||
|
const response = await connectWiseApi.get<CWCompanySite[]>(
|
||||||
|
`/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<CWCompanySite | null> {
|
||||||
|
try {
|
||||||
|
const response = await connectWiseApi.get<CWCompanySite>(
|
||||||
|
`/company/companies/${cwCompanyId}/sites/${siteId}`
|
||||||
|
);
|
||||||
|
return response.data ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-11
@@ -237,7 +237,8 @@ export function buildMockUser(overrides: Record<string, any> = {}) {
|
|||||||
return {
|
return {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
userId: "ms-uid-1",
|
userId: "ms-uid-1",
|
||||||
name: "Test User",
|
firstName: "Test",
|
||||||
|
lastName: "User",
|
||||||
login: "test@example.com",
|
login: "test@example.com",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
emailVerified: null,
|
emailVerified: null,
|
||||||
@@ -270,6 +271,7 @@ export function buildMockCompany(overrides: Record<string, any> = {}) {
|
|||||||
return {
|
return {
|
||||||
id: 123,
|
id: 123,
|
||||||
uid: "company-1",
|
uid: "company-1",
|
||||||
|
identifier: "TestCo",
|
||||||
name: "Test Company",
|
name: "Test Company",
|
||||||
phone: "555-1234",
|
phone: "555-1234",
|
||||||
website: "https://test.com",
|
website: "https://test.com",
|
||||||
@@ -334,7 +336,7 @@ export function buildMockCredential(overrides: Record<string, any> = {}) {
|
|||||||
name: "Test Credential",
|
name: "Test Credential",
|
||||||
notes: null,
|
notes: null,
|
||||||
typeId: ctype.id,
|
typeId: ctype.id,
|
||||||
companyId: company.id,
|
companyId: company.uid,
|
||||||
subCredentialOfId: null,
|
subCredentialOfId: null,
|
||||||
fields: { username: "admin" },
|
fields: { username: "admin" },
|
||||||
type: ctype,
|
type: ctype,
|
||||||
@@ -379,6 +381,7 @@ export function buildMockOpportunity(overrides: Record<string, any> = {}) {
|
|||||||
notes: "Some notes",
|
notes: "Some notes",
|
||||||
typeId: 1,
|
typeId: 1,
|
||||||
type: { id: 1, name: "New Business" },
|
type: { id: 1, name: "New Business" },
|
||||||
|
stage: { id: 2, name: "Proposal" },
|
||||||
stageName: "Proposal",
|
stageName: "Proposal",
|
||||||
stageCwId: 2,
|
stageCwId: 2,
|
||||||
statusId: 3,
|
statusId: 3,
|
||||||
@@ -398,7 +401,7 @@ export function buildMockOpportunity(overrides: Record<string, any> = {}) {
|
|||||||
secondarySalesRepCwId: null,
|
secondarySalesRepCwId: null,
|
||||||
companyCwId: 123,
|
companyCwId: 123,
|
||||||
companyName: "Test Company",
|
companyName: "Test Company",
|
||||||
companyId: "company-1",
|
companyId: 123,
|
||||||
contactCwId: 200,
|
contactCwId: 200,
|
||||||
contactName: "Jane Doe",
|
contactName: "Jane Doe",
|
||||||
siteCwId: 300,
|
siteCwId: 300,
|
||||||
@@ -517,19 +520,19 @@ export function buildMockGeneratedQuote(overrides: Record<string, any> = {}) {
|
|||||||
/** Build a minimal Prisma-shaped CatalogItem row. */
|
/** Build a minimal Prisma-shaped CatalogItem row. */
|
||||||
export function buildMockCatalogItem(overrides: Record<string, any> = {}) {
|
export function buildMockCatalogItem(overrides: Record<string, any> = {}) {
|
||||||
return {
|
return {
|
||||||
id: "cat-1",
|
id: 500,
|
||||||
cwCatalogId: 500,
|
uid: "cat-1",
|
||||||
identifier: "USW-Pro-24",
|
identifier: "USW-Pro-24",
|
||||||
name: "UniFi Switch Pro 24",
|
name: "UniFi Switch Pro 24",
|
||||||
description: "24-port managed switch",
|
description: "24-port managed switch",
|
||||||
customerDescription: "Enterprise switch",
|
customerDescription: "Enterprise switch",
|
||||||
internalNotes: null,
|
internalNotes: null,
|
||||||
category: "Technology",
|
subcategory: {
|
||||||
categoryCwId: 18,
|
id: 112,
|
||||||
subcategory: "Network-Switch",
|
name: "Network-Switch",
|
||||||
subcategoryCwId: 112,
|
category: { id: 18, name: "Technology" },
|
||||||
manufacturer: "Ubiquiti",
|
},
|
||||||
manufactureCwId: 248,
|
manufacturer: { id: 248, name: "Ubiquiti" },
|
||||||
partNumber: "USW-Pro-24",
|
partNumber: "USW-Pro-24",
|
||||||
vendorName: "Ubiquiti Inc",
|
vendorName: "Ubiquiti Inc",
|
||||||
vendorSku: "USW-Pro-24",
|
vendorSku: "USW-Pro-24",
|
||||||
|
|||||||
Reference in New Issue
Block a user