all the haul
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
CWCreateActivity,
|
||||
} from "../modules/cw-utils/activities/activity.types";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { fetchActivity } from "../modules/cw-utils/activities/fetchActivity";
|
||||
|
||||
/**
|
||||
* Activity Controller
|
||||
@@ -127,26 +126,6 @@ export class ActivityController {
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from ConnectWise
|
||||
*
|
||||
* Fetches the latest activity data from CW and returns
|
||||
* a new ActivityController instance with updated state.
|
||||
*/
|
||||
public async refreshFromCW(): Promise<ActivityController> {
|
||||
const cwData = await fetchActivity(this.cwActivityId);
|
||||
return new ActivityController(cwData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
* Returns the raw ConnectWise activity object.
|
||||
*/
|
||||
public async fetchCwData(): Promise<CWActivity> {
|
||||
return fetchActivity(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update in ConnectWise
|
||||
*
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { CatalogItem } from "../../generated/prisma/client";
|
||||
import {
|
||||
CatalogItem,
|
||||
CatalogCategory,
|
||||
CatalogSubcategory,
|
||||
CatalogManufacturer,
|
||||
} from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { catalogCw } from "../modules/cw-utils/procurement/catalog";
|
||||
import { CatalogItem as CWCatalogItem } from "../modules/cw-utils/procurement/catalog.types";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* Shape of the Prisma result when catalog items are queried with
|
||||
* `include: { linkedItems, manufacturer, subcategory: { include: { category } } }`.
|
||||
*/
|
||||
type CatalogItemWithRelations = CatalogItem & {
|
||||
linkedItems?: (CatalogItem & {
|
||||
manufacturer?: CatalogManufacturer | null;
|
||||
subcategory?: CatalogSubcategory & { category?: CatalogCategory | null };
|
||||
})[];
|
||||
manufacturer?: CatalogManufacturer | null;
|
||||
subcategory?: (CatalogSubcategory & { category?: CatalogCategory | null }) | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalog Item Controller
|
||||
*
|
||||
@@ -12,13 +30,15 @@ import GenericError from "../Errors/GenericError";
|
||||
* the internal database representation with ConnectWise catalog data.
|
||||
*/
|
||||
export class CatalogItemController {
|
||||
/** The ConnectWise catalog record ID (`CatalogItem.id` in Prisma — Int @unique). */
|
||||
public readonly cwCatalogId: number;
|
||||
/** The Prisma primary key (UUID). */
|
||||
public readonly id: string;
|
||||
public name: string;
|
||||
public description: string | null;
|
||||
public customerDescription: string | null;
|
||||
public internalNotes: string | null;
|
||||
|
||||
public readonly cwCatalogId: number;
|
||||
public readonly identifier: string | null;
|
||||
|
||||
public category: string | null;
|
||||
@@ -48,24 +68,29 @@ export class CatalogItemController {
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(
|
||||
itemData: CatalogItem & {
|
||||
linkedItems?: CatalogItem[];
|
||||
},
|
||||
) {
|
||||
this.id = itemData.id;
|
||||
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;
|
||||
this.name = itemData.name;
|
||||
this.description = itemData.description;
|
||||
this.customerDescription = itemData.customerDescription;
|
||||
this.internalNotes = itemData.internalNotes;
|
||||
this.cwCatalogId = itemData.cwCatalogId;
|
||||
this.identifier = itemData.identifier;
|
||||
this.category = itemData.category;
|
||||
this.categoryCwId = itemData.categoryCwId;
|
||||
this.subcategory = itemData.subcategory;
|
||||
this.subcategoryCwId = itemData.subcategoryCwId;
|
||||
this.manufacturer = itemData.manufacturer;
|
||||
this.manufactureCwId = itemData.manufactureCwId;
|
||||
|
||||
// Extract relation data into flat fields
|
||||
const sub = itemData.subcategory;
|
||||
const cat = sub?.category;
|
||||
const mfr = itemData.manufacturer;
|
||||
|
||||
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.partNumber = itemData.partNumber;
|
||||
this.vendorName = itemData.vendorName;
|
||||
this.vendorSku = itemData.vendorSku;
|
||||
@@ -97,7 +122,7 @@ export class CatalogItemController {
|
||||
|
||||
if (onHand !== this.onHand) {
|
||||
await prisma.catalogItem.update({
|
||||
where: { id: this.id },
|
||||
where: { uid: this.id },
|
||||
data: { onHand },
|
||||
});
|
||||
this.onHand = onHand;
|
||||
@@ -137,7 +162,7 @@ export class CatalogItemController {
|
||||
}
|
||||
|
||||
const target = await prisma.catalogItem.findFirst({
|
||||
where: { id: targetId },
|
||||
where: { uid: targetId },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
@@ -150,9 +175,9 @@ export class CatalogItemController {
|
||||
}
|
||||
|
||||
const updated = await prisma.catalogItem.update({
|
||||
where: { id: this.id },
|
||||
where: { uid: this.id },
|
||||
data: {
|
||||
linkedItems: { connect: { id: targetId } },
|
||||
linkedItems: { connect: { uid: targetId } },
|
||||
},
|
||||
include: { linkedItems: true },
|
||||
});
|
||||
@@ -174,9 +199,9 @@ export class CatalogItemController {
|
||||
*/
|
||||
public async unlinkItem(targetId: string): Promise<CatalogItemController> {
|
||||
const updated = await prisma.catalogItem.update({
|
||||
where: { id: this.id },
|
||||
where: { uid: this.id },
|
||||
data: {
|
||||
linkedItems: { disconnect: { id: targetId } },
|
||||
linkedItems: { disconnect: { uid: targetId } },
|
||||
},
|
||||
include: { linkedItems: true },
|
||||
});
|
||||
|
||||
@@ -1,204 +1,270 @@
|
||||
import { Company } from "../../generated/prisma/client";
|
||||
import { connectWiseApi } from "../constants";
|
||||
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
||||
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
||||
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
|
||||
import {
|
||||
fetchCompanySites,
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
|
||||
Company,
|
||||
CompanyAddress,
|
||||
Contact,
|
||||
} from "../../generated/prisma/client";
|
||||
import { connectWiseApi, prisma } from "../constants";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { processConfigurationResponse } from "../modules/cw-utils/configurations/processConfigurationResponse";
|
||||
import { withCwRetry } from "../modules/cw-utils/withCwRetry";
|
||||
import type { ConfigurationResponse } from "../types/ConnectWiseTypes";
|
||||
|
||||
// Type for company data with relations
|
||||
type CompanyWithRelations = Company & {
|
||||
contacts?: Contact[];
|
||||
companyAddresses?: CompanyAddress[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Company Controller
|
||||
*
|
||||
* This class is for creating a controller that can manage company data,
|
||||
* synchronize with external systems, and provide methods for accessing
|
||||
* and updating company information within our internal system.
|
||||
* This class manages company data from the local database.
|
||||
* Data is synced from ConnectWise via the dalpuri service.
|
||||
*/
|
||||
export class CompanyController {
|
||||
public readonly id: string;
|
||||
public readonly id: number;
|
||||
public readonly uid: string;
|
||||
public name: string;
|
||||
public readonly cw_Identifier: string;
|
||||
public readonly cw_CompanyId: number;
|
||||
public cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact | null;
|
||||
allContacts: Contact[];
|
||||
};
|
||||
public phone: string | null;
|
||||
public website: string | null;
|
||||
|
||||
constructor(companyData: Company, cwData?: typeof this.cw_Data) {
|
||||
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;
|
||||
this.name = companyData.name;
|
||||
this.cw_Identifier = companyData.cw_Identifier;
|
||||
this.cw_CompanyId = companyData.cw_CompanyId;
|
||||
this.cw_Data = cwData;
|
||||
this.phone = companyData.phone;
|
||||
this.website = companyData.website;
|
||||
|
||||
if (companyData.contacts) {
|
||||
this._contacts = companyData.contacts;
|
||||
this._defaultContact =
|
||||
companyData.contacts.find((c) => c.default) ?? null;
|
||||
}
|
||||
|
||||
if (companyData.companyAddresses) {
|
||||
this._addresses = companyData.companyAddresses;
|
||||
this._defaultAddress =
|
||||
companyData.companyAddresses.find((a) => a.defaultFlag) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate CW Data
|
||||
* Hydrate Data
|
||||
*
|
||||
* Fetches and populates the full ConnectWise company data
|
||||
* (company, default contact, all contacts) if not already loaded.
|
||||
*
|
||||
* @returns {ThisType}
|
||||
* Loads contacts and addresses from the local database if not already loaded.
|
||||
*/
|
||||
public async hydrateCwData() {
|
||||
if (this.cw_Data) return this;
|
||||
public async hydrateData() {
|
||||
if (this._contacts.length === 0) {
|
||||
this._contacts = await prisma.contact.findMany({
|
||||
where: { companyId: this.id },
|
||||
});
|
||||
this._defaultContact = this._contacts.find((c) => c.default) ?? null;
|
||||
}
|
||||
|
||||
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
|
||||
if (!cwCompany) return this;
|
||||
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${cwCompany._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
// Derive default contact from allContacts instead of a separate CW call
|
||||
const defaultContactId = cwCompany.defaultContact?.id;
|
||||
const defaultContactData = defaultContactId
|
||||
? ((allContactsData.data as any[]).find(
|
||||
(c: any) => c.id === defaultContactId,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
this.cw_Data = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
if (this._addresses.length === 0) {
|
||||
this._addresses = await prisma.companyAddress.findMany({
|
||||
where: { companyId: this.id },
|
||||
});
|
||||
this._defaultAddress = this._addresses.find((a) => a.defaultFlag) ?? null;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Internal Company Data from ConnectWise
|
||||
* Refresh from DB
|
||||
*
|
||||
* This method fetches the latest company data from ConnectWise and updates
|
||||
* the internal company information accordingly.
|
||||
*
|
||||
* @returns {ThisType} - Updated Controller
|
||||
* Reloads the company data from the local database.
|
||||
*/
|
||||
public async refreshFromCW() {
|
||||
const data = await updateCwInternalCompany(this.cw_CompanyId);
|
||||
public async refreshFromDb() {
|
||||
const data = await prisma.company.findUnique({
|
||||
where: { id: this.id },
|
||||
});
|
||||
|
||||
if (data) {
|
||||
this.name = data.name;
|
||||
this.phone = data.phone;
|
||||
this.website = data.website;
|
||||
}
|
||||
|
||||
this.name = data?.name || this.name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ConnectWise Company Data
|
||||
*
|
||||
* This method retrieves the latest company data directly from ConnectWise
|
||||
* using the stored ConnectWise Company ID.
|
||||
*
|
||||
* @returns {Company}
|
||||
*/
|
||||
public async fetchCwData(): Promise<CWCompany | null> {
|
||||
const data = await fetchCwCompanyById(this.cw_CompanyId);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Configurations
|
||||
*
|
||||
* This method retrieves the configurations associated with
|
||||
* the company from ConnectWise.
|
||||
*
|
||||
* @returns {ProcessedConfiguration}
|
||||
* Fetches configurations directly from ConnectWise.
|
||||
*/
|
||||
public async fetchConfigurations() {
|
||||
const data = await fetchCompanyConfigurations(this.cw_CompanyId);
|
||||
return data;
|
||||
const pageSize = 1000;
|
||||
const conditions = encodeURIComponent(`company/id=${this.id}`);
|
||||
const configurations: ConfigurationResponse = [];
|
||||
|
||||
try {
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await withCwRetry(
|
||||
() =>
|
||||
connectWiseApi.get<ConfigurationResponse>(
|
||||
`/company/configurations?page=${page}&pageSize=${pageSize}&conditions=${conditions}`,
|
||||
),
|
||||
{ label: `company-configurations:${this.id}:page-${page}` },
|
||||
);
|
||||
|
||||
const items = Array.isArray(response.data) ? response.data : [];
|
||||
configurations.push(...items);
|
||||
|
||||
if (items.length < pageSize) break;
|
||||
}
|
||||
|
||||
return processConfigurationResponse(configurations);
|
||||
} catch (error: any) {
|
||||
const cwStatus = Number(error?.response?.status);
|
||||
const status = cwStatus >= 400 && cwStatus <= 599 ? cwStatus : 502;
|
||||
|
||||
throw new GenericError({
|
||||
message: `Failed to fetch company configurations from ConnectWise`,
|
||||
name: "ConnectWiseFetchFailed",
|
||||
cause:
|
||||
error?.response?.data?.message ??
|
||||
error?.response?.statusText ??
|
||||
error?.message ??
|
||||
"Unknown ConnectWise error",
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Sites
|
||||
*
|
||||
* Retrieves all sites for this company from ConnectWise
|
||||
* and returns them as serialized site objects.
|
||||
* Retrieves all sites (addresses) for this company from local DB.
|
||||
*/
|
||||
public async fetchSites() {
|
||||
const sites = await fetchCompanySites(this.cw_CompanyId);
|
||||
return sites.map(serializeCwSite);
|
||||
const sites = await prisma.companyAddress.findMany({
|
||||
where: { companyId: this.id },
|
||||
});
|
||||
|
||||
return sites.map((site) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
addressLine1: site.addressLine1,
|
||||
addressLine2: site.addressLine2,
|
||||
city: site.city,
|
||||
state: site.state,
|
||||
zip: site.zipCode,
|
||||
country: site.country,
|
||||
phone: site.phone,
|
||||
fax: site.fax,
|
||||
defaultFlag: site.defaultFlag,
|
||||
inactiveFlag: site.inactiveFlag,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Site by ID
|
||||
*
|
||||
* Retrieves a single site by its ConnectWise site ID
|
||||
* and returns a serialized site object.
|
||||
*
|
||||
* @param cwSiteId - The ConnectWise site ID
|
||||
* Retrieves a single site by its ID from local DB.
|
||||
*/
|
||||
public async fetchSite(cwSiteId: number) {
|
||||
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
|
||||
return serializeCwSite(site);
|
||||
public async fetchSite(siteId: number) {
|
||||
const site = await prisma.companyAddress.findFirst({
|
||||
where: { id: siteId, companyId: this.id },
|
||||
});
|
||||
|
||||
if (!site) return null;
|
||||
|
||||
return {
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
addressLine1: site.addressLine1,
|
||||
addressLine2: site.addressLine2,
|
||||
city: site.city,
|
||||
state: site.state,
|
||||
zip: site.zipCode,
|
||||
country: site.country,
|
||||
phone: site.phone,
|
||||
fax: site.fax,
|
||||
defaultFlag: site.defaultFlag,
|
||||
inactiveFlag: site.inactiveFlag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contacts
|
||||
*/
|
||||
public getContacts() {
|
||||
return this._contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default contact
|
||||
*/
|
||||
public getDefaultContact() {
|
||||
return this._defaultContact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default address
|
||||
*/
|
||||
public getDefaultAddress() {
|
||||
return this._defaultAddress;
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeAddress: boolean;
|
||||
includePrimaryContact: boolean;
|
||||
includeAddress?: boolean;
|
||||
includePrimaryContact?: boolean;
|
||||
includeAllContacts?: boolean;
|
||||
}) {
|
||||
const cw_Data: Record<string, unknown> = {};
|
||||
|
||||
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?.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?.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,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
id: this.uid,
|
||||
name: this.name,
|
||||
cw_Identifier: this.cw_Identifier,
|
||||
cw_CompanyId: this.cw_CompanyId,
|
||||
cw_Data: {
|
||||
address: !opts?.includeAddress
|
||||
? undefined
|
||||
: {
|
||||
line1: this.cw_Data?.company.addressLine1,
|
||||
line2: this.cw_Data?.company.addressLine2 ?? null,
|
||||
city: this.cw_Data?.company.city,
|
||||
state: this.cw_Data?.company.state,
|
||||
zip: this.cw_Data?.company.zip,
|
||||
country: this.cw_Data?.company.country
|
||||
? this.cw_Data.company.country.name
|
||||
: "United States",
|
||||
},
|
||||
primaryContact: !opts?.includePrimaryContact
|
||||
? undefined
|
||||
: this.cw_Data?.defaultContact
|
||||
? {
|
||||
firstName: this.cw_Data.defaultContact.firstName,
|
||||
lastName: this.cw_Data.defaultContact.lastName,
|
||||
cwId: this.cw_Data.defaultContact.id,
|
||||
inactive: this.cw_Data.defaultContact.inactiveFlag,
|
||||
title: this.cw_Data.defaultContact.title,
|
||||
phone: this.cw_Data.defaultContact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!this.cw_Data?.defaultContact?.communicationItems)
|
||||
return null;
|
||||
return (
|
||||
this.cw_Data.defaultContact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
allContacts: !opts?.includeAllContacts
|
||||
? undefined
|
||||
: this.cw_Data?.allContacts.map((contact) => ({
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
cwId: contact.id,
|
||||
inactive: contact.inactiveFlag,
|
||||
title: contact.title,
|
||||
phone: contact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!contact.communicationItems) return null;
|
||||
return (
|
||||
contact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
})),
|
||||
},
|
||||
cw_CompanyId: this.id,
|
||||
cw_Data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CwMember } from "../../generated/prisma/client";
|
||||
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
|
||||
|
||||
/**
|
||||
* CW Member Controller
|
||||
@@ -44,24 +43,6 @@ export class CwMemberController {
|
||||
return name || this.identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CW Member → Prisma create/update payload
|
||||
*
|
||||
* Static helper used by both the controller and the refresh sync.
|
||||
*/
|
||||
public static mapCwToDb(item: CWMember) {
|
||||
return {
|
||||
identifier: item.identifier,
|
||||
firstName: item.firstName ?? "",
|
||||
lastName: item.lastName ?? "",
|
||||
officeEmail: item.officeEmail ?? null,
|
||||
inactiveFlag: item.inactiveFlag ?? false,
|
||||
cwLastUpdated: item._info?.lastUpdated
|
||||
? new Date(item._info.lastUpdated)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
|
||||
@@ -8,6 +8,9 @@ import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.ty
|
||||
* locally — all data is sourced directly from the ConnectWise API.
|
||||
*/
|
||||
export class ForecastProductController {
|
||||
private static readonly PROCUREMENT_NOTES_FIELD_ID = 29;
|
||||
private static readonly PRODUCT_NARRATIVE_FIELD_ID = 46;
|
||||
|
||||
public readonly cwForecastId: number;
|
||||
public forecastDescription: string;
|
||||
|
||||
@@ -24,6 +27,8 @@ export class ForecastProductController {
|
||||
|
||||
public productDescription: string;
|
||||
public customerDescription: string | null;
|
||||
public description: string | null;
|
||||
public procurementNotes: string | null;
|
||||
public productNarrative: string | null;
|
||||
public productClass: string;
|
||||
public forecastType: string;
|
||||
@@ -49,6 +54,11 @@ export class ForecastProductController {
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
// Pricing data (from local ProductData)
|
||||
public unitPrice: number;
|
||||
public listPrice: number;
|
||||
public discount: number;
|
||||
|
||||
// Cancellation data (from procurement products endpoint)
|
||||
public cancelledFlag: boolean;
|
||||
public quantityCancelled: number;
|
||||
@@ -77,8 +87,23 @@ export class ForecastProductController {
|
||||
|
||||
this.productDescription = data.productDescription;
|
||||
this.customerDescription = data.customerDescription ?? null;
|
||||
this.description = null;
|
||||
this.procurementNotes =
|
||||
data.procurementNotes ??
|
||||
data.customFields
|
||||
?.find(
|
||||
(f) => f.id === ForecastProductController.PROCUREMENT_NOTES_FIELD_ID
|
||||
)
|
||||
?.value?.toString() ??
|
||||
null;
|
||||
this.productNarrative =
|
||||
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
|
||||
data.productNarrative ??
|
||||
data.customFields
|
||||
?.find(
|
||||
(f) => f.id === ForecastProductController.PRODUCT_NARRATIVE_FIELD_ID
|
||||
)
|
||||
?.value?.toString() ??
|
||||
null;
|
||||
this.productClass = data.productClass;
|
||||
this.forecastType = data.forecastType;
|
||||
|
||||
@@ -105,6 +130,11 @@ export class ForecastProductController {
|
||||
: null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
|
||||
// Pricing defaults — enriched later via applyPricingData()
|
||||
this.unitPrice = 0;
|
||||
this.listPrice = 0;
|
||||
this.discount = 0;
|
||||
|
||||
// Cancellation defaults — enriched later via applyCancellationData()
|
||||
this.cancelledFlag = false;
|
||||
this.quantityCancelled = 0;
|
||||
@@ -133,8 +163,19 @@ export class ForecastProductController {
|
||||
public applyProcurementCustomFields(data: {
|
||||
customFields?: Array<{ id: number; value?: unknown }>;
|
||||
}): void {
|
||||
const notes = data.customFields
|
||||
?.find(
|
||||
(f) => f.id === ForecastProductController.PROCUREMENT_NOTES_FIELD_ID
|
||||
)
|
||||
?.value?.toString();
|
||||
if (notes) {
|
||||
this.procurementNotes = notes;
|
||||
}
|
||||
|
||||
const narrative = data.customFields
|
||||
?.find((f) => f.id === 46)
|
||||
?.find(
|
||||
(f) => f.id === ForecastProductController.PRODUCT_NARRATIVE_FIELD_ID
|
||||
)
|
||||
?.value?.toString();
|
||||
if (narrative) {
|
||||
this.productNarrative = narrative;
|
||||
@@ -257,6 +298,7 @@ export class ForecastProductController {
|
||||
: null,
|
||||
productDescription: this.productDescription,
|
||||
customerDescription: this.customerDescription,
|
||||
procurementNotes: this.procurementNotes,
|
||||
productNarrative: this.productNarrative,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
@@ -281,6 +323,25 @@ export class ForecastProductController {
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
onHand: this.onHand,
|
||||
inStock: this.inStock,
|
||||
unitPrice: this.unitPrice,
|
||||
listPrice: this.listPrice,
|
||||
discount: this.discount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Pricing Data
|
||||
*
|
||||
* Enriches this forecast product with pricing data from the local
|
||||
* ProductData table.
|
||||
*/
|
||||
public applyPricingData(data: {
|
||||
unitPrice?: number;
|
||||
listPrice?: number;
|
||||
discount?: number;
|
||||
}): void {
|
||||
this.unitPrice = data.unitPrice ?? 0;
|
||||
this.listPrice = data.listPrice ?? 0;
|
||||
this.discount = data.discount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class GeneratedQuoteController {
|
||||
data: GeneratedQuotes & {
|
||||
opportunity?: Opportunity | null;
|
||||
createdBy?: (User & { roles: Role[] }) | null;
|
||||
},
|
||||
}
|
||||
) {
|
||||
this.id = data.id;
|
||||
|
||||
@@ -67,7 +67,7 @@ export class GeneratedQuoteController {
|
||||
if (this._opportunity) return this._opportunity;
|
||||
|
||||
const opportunity = await prisma.opportunity.findFirst({
|
||||
where: { id: this.opportunityId },
|
||||
where: { uid: this.opportunityId },
|
||||
});
|
||||
|
||||
if (!opportunity) return null;
|
||||
@@ -114,8 +114,8 @@ export class GeneratedQuoteController {
|
||||
quoteFile: !opts?.includeFile
|
||||
? undefined
|
||||
: opts?.encodeFileAsBase64
|
||||
? Buffer.from(this.quoteFile).toString("base64")
|
||||
: this.quoteFile,
|
||||
? Buffer.from(this.quoteFile).toString("base64")
|
||||
: this.quoteFile,
|
||||
opportunity:
|
||||
opts?.includeOpportunity && this._opportunity
|
||||
? this._opportunity.toJson()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,7 @@ export class RoleController {
|
||||
});
|
||||
throw new PermissionsVerificationError(
|
||||
`Unable to verify permissions for role '${this.title}, it is recommended that you override and rewrite these permissions immediately.`,
|
||||
(err as Error).message,
|
||||
(err as Error).message
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,14 +261,14 @@ export class RoleController {
|
||||
title: string;
|
||||
moniker: string;
|
||||
permissions: string[];
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const schema = z
|
||||
.object({
|
||||
title: z.string().min(1, "Title cannot be empty."),
|
||||
moniker: z.string().min(1, "Moniker cannot be empty."),
|
||||
permissions: z.array(
|
||||
z.string().min(1, "Permission node cannot be empty"),
|
||||
z.string().min(1, "Permission node cannot be empty")
|
||||
),
|
||||
})
|
||||
.partial()
|
||||
@@ -284,7 +284,7 @@ export class RoleController {
|
||||
if (checkMoniker && checkMoniker.moniker !== this.moniker)
|
||||
throw new RoleError(
|
||||
"Moniker is already taken.",
|
||||
"Another role with this moniker already exists in the databse.",
|
||||
"Another role with this moniker already exists in the databse."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -337,7 +337,10 @@ export class RoleController {
|
||||
users: opts?.viewUsers
|
||||
? this._users.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
name:
|
||||
`${v.firstName ?? ""} ${v.lastName ?? ""}`.trim() ||
|
||||
v.login ||
|
||||
v.email,
|
||||
login: v.login,
|
||||
roles: v.roles.map((r: any) => r.id),
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Schedule,
|
||||
ScheduleStatus,
|
||||
ScheduleType,
|
||||
ScheduleSpan,
|
||||
} from "../../generated/prisma/client";
|
||||
|
||||
type ScheduleWithRelations = Schedule & {
|
||||
status?: ScheduleStatus | null;
|
||||
type?: ScheduleType | null;
|
||||
scheduleSpan?: ScheduleSpan | null;
|
||||
};
|
||||
|
||||
export class ScheduleController {
|
||||
public readonly id: number;
|
||||
public readonly uid: string;
|
||||
public name: string;
|
||||
public description: string | null;
|
||||
|
||||
private _data: ScheduleWithRelations;
|
||||
|
||||
constructor(data: ScheduleWithRelations) {
|
||||
this.id = data.id;
|
||||
this.uid = data.uid;
|
||||
this.name = data.name;
|
||||
this.description = data.description;
|
||||
this._data = data;
|
||||
}
|
||||
|
||||
public toJson() {
|
||||
const d = this._data;
|
||||
return {
|
||||
id: d.uid,
|
||||
cwId: d.id,
|
||||
memberId: d.memberId,
|
||||
name: d.name,
|
||||
description: d.description,
|
||||
closedFlag: d.closedFlag,
|
||||
reminderFlag: d.reminderFlag,
|
||||
allDayFlag: d.allDayFlag,
|
||||
acknowledgementFlag: d.acknowledgementFlag,
|
||||
meetingFlag: d.meetingFlag,
|
||||
recurringFlag: d.recurringFlag,
|
||||
billableFlag: d.billableFlag,
|
||||
acknowledgedById: d.acknowledgedById,
|
||||
acknowledgedAt: d.acknowledgedAt,
|
||||
startDate: d.startDate,
|
||||
endDate: d.endDate,
|
||||
hoursScheduled: d.hoursScheduled,
|
||||
duration: d.duration,
|
||||
hoursPerDay: d.hoursPerDay,
|
||||
reminderMinutes: d.reminderMinutes,
|
||||
closedById: d.closedById,
|
||||
closedAt: d.closedAt,
|
||||
createdById: d.createdById,
|
||||
updatedById: d.updatedById,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
status: d.status
|
||||
? {
|
||||
id: d.status.id,
|
||||
uid: d.status.uid,
|
||||
name: d.status.name,
|
||||
color: d.status.color,
|
||||
}
|
||||
: null,
|
||||
type: d.type
|
||||
? {
|
||||
id: d.type.id,
|
||||
uid: d.type.uid,
|
||||
name: d.type.name,
|
||||
displayColor: d.type.displayColor,
|
||||
}
|
||||
: null,
|
||||
scheduleSpan: d.scheduleSpan
|
||||
? {
|
||||
id: d.scheduleSpan.id,
|
||||
scheduleSpanId: d.scheduleSpan.scheduleSpanId,
|
||||
spanDesc: d.scheduleSpan.spanDesc,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { Role } from "../../generated/prisma/client";
|
||||
import { User } from "../../generated/prisma/browser";
|
||||
import { Role, User } from "../../generated/prisma/client";
|
||||
import { SessionTokensObject } from "./SessionController";
|
||||
import { sessions } from "../managers/sessions";
|
||||
import BodyError from "../Errors/BodyError";
|
||||
@@ -15,11 +14,13 @@ import { permissionsPrivateKey } from "../constants";
|
||||
|
||||
export default class UserController {
|
||||
public id: string;
|
||||
public name: string | null;
|
||||
public firstName: string | null;
|
||||
public lastName: string | null;
|
||||
public login: string;
|
||||
public email: string;
|
||||
public image: string | null;
|
||||
public cwIdentifier: string | null;
|
||||
public cwMemberId: number | null;
|
||||
|
||||
private _roles: Collection<string, Role>;
|
||||
private _permissions: string | null;
|
||||
@@ -33,13 +34,38 @@ export default class UserController {
|
||||
|
||||
public createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
public get name(): string | null {
|
||||
const full = [this.firstName, this.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
return full.length > 0 ? full : null;
|
||||
}
|
||||
|
||||
private _splitName(name: string): {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
} {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { firstName: null, lastName: null };
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const firstName = parts.shift() ?? null;
|
||||
const lastName = parts.length > 0 ? parts.join(" ") : null;
|
||||
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
constructor(userdata: User & { roles: Role[] }) {
|
||||
this.id = userdata.id;
|
||||
this.name = userdata.name;
|
||||
this.firstName = userdata.firstName ?? null;
|
||||
this.lastName = userdata.lastName ?? null;
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.cwMemberId = userdata.cwMemberId ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
this._permissions = userdata.permissions ?? null;
|
||||
@@ -62,11 +88,13 @@ export default class UserController {
|
||||
*/
|
||||
private _updateInternalValues(userdata: User) {
|
||||
this.id = userdata.id;
|
||||
this.name = userdata.name;
|
||||
this.firstName = userdata.firstName ?? null;
|
||||
this.lastName = userdata.lastName ?? null;
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.cwMemberId = userdata.cwMemberId ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
}
|
||||
@@ -92,17 +120,33 @@ export default class UserController {
|
||||
* @param data - A partial of the user data
|
||||
* @returns {Promise<UserController>} - The updated user controller
|
||||
*/
|
||||
public async update(data: Partial<Pick<User, "name" | "image">>) {
|
||||
if (Object.keys(data).length == 0)
|
||||
public async update(
|
||||
data: Partial<Pick<User, "firstName" | "lastName" | "image">> & {
|
||||
name?: string;
|
||||
}
|
||||
) {
|
||||
const updateData: Partial<Pick<User, "firstName" | "lastName" | "image">> =
|
||||
{};
|
||||
|
||||
if (data.image !== undefined) updateData.image = data.image;
|
||||
if (data.name !== undefined) {
|
||||
const parsed = this._splitName(data.name);
|
||||
updateData.firstName = parsed.firstName;
|
||||
updateData.lastName = parsed.lastName;
|
||||
}
|
||||
if (data.firstName !== undefined) updateData.firstName = data.firstName;
|
||||
if (data.lastName !== undefined) updateData.lastName = data.lastName;
|
||||
|
||||
if (Object.keys(updateData).length == 0)
|
||||
throw new BodyError("Body cannot be empty.");
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: this.id },
|
||||
data,
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this._updateInternalValues(updatedUser);
|
||||
events.emit("user:updated", { user: this, updatedValues: data });
|
||||
events.emit("user:updated", { user: this, updatedValues: updateData });
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -118,7 +162,7 @@ export default class UserController {
|
||||
*/
|
||||
public async setRoles(roleIdentifiers: string[]): Promise<UserController> {
|
||||
const resolvedRoles = await Promise.all(
|
||||
roleIdentifiers.map((identifier) => roles.fetch(identifier)),
|
||||
roleIdentifiers.map((identifier) => roles.fetch(identifier))
|
||||
);
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
@@ -242,8 +286,8 @@ export default class UserController {
|
||||
|
||||
await Promise.all(
|
||||
this._roles.map(async (v) =>
|
||||
collection.set(v.id, await roles.fetch(v.id)),
|
||||
),
|
||||
collection.set(v.id, await roles.fetch(v.id))
|
||||
)
|
||||
);
|
||||
|
||||
return collection;
|
||||
@@ -307,11 +351,13 @@ export default class UserController {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
firstName: this.firstName,
|
||||
lastName: this.lastName,
|
||||
roles: opts?.safeReturn
|
||||
? undefined
|
||||
: this._roles.size > 0
|
||||
? this._roles.map((v) => v.moniker)
|
||||
: undefined,
|
||||
? this._roles.map((v) => v.moniker)
|
||||
: undefined,
|
||||
permissions: opts?.safeReturn
|
||||
? undefined
|
||||
: (() => {
|
||||
@@ -325,6 +371,7 @@ export default class UserController {
|
||||
login: opts?.safeReturn ? undefined : this.login,
|
||||
email: opts?.safeReturn ? undefined : this.email,
|
||||
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
|
||||
cwMemberId: this.cwMemberId,
|
||||
image: this.image,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
|
||||
Reference in New Issue
Block a user