Fix UserController permission serialization and include current updates

This commit is contained in:
2026-02-27 14:38:22 -06:00
parent 51eb36f4a6
commit b1f6462ac3
50 changed files with 6150 additions and 30 deletions
+218
View File
@@ -0,0 +1,218 @@
import { CatalogItem } 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";
/**
* Catalog Item Controller
*
* This class encapsulates a catalog item entity and provides domain methods
* for accessing, refreshing, and serializing catalog item data. It bridges
* the internal database representation with ConnectWise catalog data.
*/
export class CatalogItemController {
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 manufacturer: string | null;
public manufactureCwId: number | null;
public partNumber: string | null;
public vendorName: string | null;
public vendorSku: string | null;
public vendorCwId: number | null;
public price: number;
public cost: number;
public inactive: boolean;
public salesTaxable: boolean;
public onHand: number;
public cwLastUpdated: Date | null;
private _linkedItems: CatalogItemController[];
public readonly createdAt: Date;
public updatedAt: Date;
constructor(
itemData: CatalogItem & {
linkedItems?: CatalogItem[];
},
) {
this.id = itemData.id;
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.manufacturer = itemData.manufacturer;
this.manufactureCwId = itemData.manufactureCwId;
this.partNumber = itemData.partNumber;
this.vendorName = itemData.vendorName;
this.vendorSku = itemData.vendorSku;
this.vendorCwId = itemData.vendorCwId;
this.price = itemData.price;
this.cost = itemData.cost;
this.inactive = itemData.inactive;
this.salesTaxable = itemData.salesTaxable;
this.onHand = itemData.onHand;
this.cwLastUpdated = itemData.cwLastUpdated;
this.createdAt = itemData.createdAt;
this.updatedAt = itemData.updatedAt;
this._linkedItems = (itemData.linkedItems ?? []).map(
(linked) => new CatalogItemController(linked),
);
}
/**
* Refresh Inventory
*
* Fetches the latest on-hand inventory count from ConnectWise
* and updates both the controller state and the database.
*
* @returns {Promise<CatalogItemController>} - The updated controller
*/
public async refreshInventory(): Promise<CatalogItemController> {
const onHand = await catalogCw.fetchInventoryOnHand(this.cwCatalogId);
if (onHand !== this.onHand) {
await prisma.catalogItem.update({
where: { id: this.id },
data: { onHand },
});
this.onHand = onHand;
}
return this;
}
/**
* Fetch Linked Items
*
* Returns the linked catalog items as an array of controllers.
*
* @returns {CatalogItemController[]} - Array of linked item controllers
*/
public getLinkedItems(): CatalogItemController[] {
return this._linkedItems;
}
/**
* Link Item
*
* Links another catalog item to this item. The relationship is bidirectional
* via the Prisma implicit many-to-many.
*
* @param targetId - The internal ID of the catalog item to link
* @returns {Promise<CatalogItemController>} - The updated controller
*/
public async linkItem(targetId: string): Promise<CatalogItemController> {
if (targetId === this.id) {
throw new GenericError({
message: "Cannot link a catalog item to itself",
name: "InvalidLinkTarget",
cause: `Item '${this.id}' cannot be linked to itself`,
status: 400,
});
}
const target = await prisma.catalogItem.findFirst({
where: { id: targetId },
});
if (!target) {
throw new GenericError({
message: "Target catalog item not found",
name: "CatalogItemNotFound",
cause: `No catalog item exists with ID '${targetId}'`,
status: 404,
});
}
const updated = await prisma.catalogItem.update({
where: { id: this.id },
data: {
linkedItems: { connect: { id: targetId } },
},
include: { linkedItems: true },
});
this._linkedItems = (updated.linkedItems ?? []).map(
(linked) => new CatalogItemController(linked),
);
return this;
}
/**
* Unlink Item
*
* Removes the link between this catalog item and another.
*
* @param targetId - The internal ID of the catalog item to unlink
* @returns {Promise<CatalogItemController>} - The updated controller
*/
public async unlinkItem(targetId: string): Promise<CatalogItemController> {
const updated = await prisma.catalogItem.update({
where: { id: this.id },
data: {
linkedItems: { disconnect: { id: targetId } },
},
include: { linkedItems: true },
});
this._linkedItems = (updated.linkedItems ?? []).map(
(linked) => new CatalogItemController(linked),
);
return this;
}
/**
* To JSON
*
* Serializes the catalog item into a safe, API-friendly object.
*
* @param opts - Options to control output
* @returns - A JSON-safe representation of the catalog item
*/
public toJson(opts?: { includeLinkedItems?: boolean }): Record<string, any> {
return {
id: this.id,
cwCatalogId: this.cwCatalogId,
identifier: this.identifier,
name: this.name,
description: this.description,
customerDescription: this.customerDescription,
internalNotes: this.internalNotes,
manufacturer: this.manufacturer,
manufactureCwId: this.manufactureCwId,
partNumber: this.partNumber,
vendorName: this.vendorName,
vendorSku: this.vendorSku,
vendorCwId: this.vendorCwId,
price: this.price,
cost: this.cost,
inactive: this.inactive,
salesTaxable: this.salesTaxable,
onHand: this.onHand,
cwLastUpdated: this.cwLastUpdated,
linkedItems: opts?.includeLinkedItems
? this._linkedItems.map((item) => item.toJson())
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+20 -18
View File
@@ -18,7 +18,7 @@ export class CompanyController {
public readonly cw_CompanyId: number;
public readonly cw_Data?: {
company: CWCompany;
defaultContact: Contact;
defaultContact: Contact | null;
allContacts: Contact[];
};
@@ -96,23 +96,25 @@ export class CompanyController {
},
primaryContact: !opts?.includePrimaryContact
? undefined
: {
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
);
})(),
},
: 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) => ({
+290
View File
@@ -0,0 +1,290 @@
import { Opportunity } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
import { CWOpportunity } from "../modules/cw-utils/opportunities/opportunity.types";
/**
* Opportunity Controller
*
* Domain model class that encapsulates an Opportunity entity and provides
* methods for accessing, refreshing from ConnectWise, and serializing
* opportunity data.
*/
export class OpportunityController {
public readonly id: string;
public readonly cwOpportunityId: number;
public name: string;
public notes: string | null;
public typeName: string | null;
public typeCwId: number | null;
public stageName: string | null;
public stageCwId: number | null;
public statusName: string | null;
public statusCwId: number | null;
public priorityName: string | null;
public priorityCwId: number | null;
public ratingName: string | null;
public ratingCwId: number | null;
public source: string | null;
public campaignName: string | null;
public campaignCwId: number | null;
public primarySalesRepName: string | null;
public primarySalesRepIdentifier: string | null;
public primarySalesRepCwId: number | null;
public secondarySalesRepName: string | null;
public secondarySalesRepIdentifier: string | null;
public secondarySalesRepCwId: number | null;
public companyCwId: number | null;
public companyName: string | null;
public contactCwId: number | null;
public contactName: string | null;
public siteCwId: number | null;
public siteName: string | null;
public customerPO: string | null;
public totalSalesTax: number;
public locationName: string | null;
public locationCwId: number | null;
public departmentName: string | null;
public departmentCwId: number | null;
public expectedCloseDate: Date | null;
public pipelineChangeDate: Date | null;
public dateBecameLead: Date | null;
public closedDate: Date | null;
public closedFlag: boolean;
public closedByName: string | null;
public closedByCwId: number | null;
public companyId: string | null;
public cwLastUpdated: Date | null;
public readonly createdAt: Date;
public updatedAt: Date;
constructor(data: Opportunity) {
this.id = data.id;
this.cwOpportunityId = data.cwOpportunityId;
this.name = data.name;
this.notes = data.notes;
this.typeName = data.typeName;
this.typeCwId = data.typeCwId;
this.stageName = data.stageName;
this.stageCwId = data.stageCwId;
this.statusName = data.statusName;
this.statusCwId = data.statusCwId;
this.priorityName = data.priorityName;
this.priorityCwId = data.priorityCwId;
this.ratingName = data.ratingName;
this.ratingCwId = data.ratingCwId;
this.source = data.source;
this.campaignName = data.campaignName;
this.campaignCwId = data.campaignCwId;
this.primarySalesRepName = data.primarySalesRepName;
this.primarySalesRepIdentifier = data.primarySalesRepIdentifier;
this.primarySalesRepCwId = data.primarySalesRepCwId;
this.secondarySalesRepName = data.secondarySalesRepName;
this.secondarySalesRepIdentifier = data.secondarySalesRepIdentifier;
this.secondarySalesRepCwId = data.secondarySalesRepCwId;
this.companyCwId = data.companyCwId;
this.companyName = data.companyName;
this.contactCwId = data.contactCwId;
this.contactName = data.contactName;
this.siteCwId = data.siteCwId;
this.siteName = data.siteName;
this.customerPO = data.customerPO;
this.totalSalesTax = data.totalSalesTax;
this.locationName = data.locationName;
this.locationCwId = data.locationCwId;
this.departmentName = data.departmentName;
this.departmentCwId = data.departmentCwId;
this.expectedCloseDate = data.expectedCloseDate;
this.pipelineChangeDate = data.pipelineChangeDate;
this.dateBecameLead = data.dateBecameLead;
this.closedDate = data.closedDate;
this.closedFlag = data.closedFlag;
this.closedByName = data.closedByName;
this.closedByCwId = data.closedByCwId;
this.companyId = data.companyId;
this.cwLastUpdated = data.cwLastUpdated;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
/**
* Refresh from ConnectWise
*
* Fetches the latest opportunity data from CW and updates
* the local database record and controller state.
*/
public async refreshFromCW(): Promise<OpportunityController> {
const cwData = await fetchOpportunity(this.cwOpportunityId);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
});
return new OpportunityController(updated);
}
/**
* Fetch raw CW data
*
* Returns the raw ConnectWise opportunity object without updating the DB.
*/
public async fetchCwData(): Promise<CWOpportunity> {
return fetchOpportunity(this.cwOpportunityId);
}
/**
* Map CW Opportunity → Prisma create/update payload
*
* Static helper used by both the controller and the refresh sync.
*/
public static mapCwToDb(item: CWOpportunity) {
return {
name: item.name,
notes: item.notes ?? null,
typeName: item.type?.name ?? null,
typeCwId: item.type?.id ?? null,
stageName: item.stage?.name ?? null,
stageCwId: item.stage?.id ?? null,
statusName: item.status?.name ?? null,
statusCwId: item.status?.id ?? null,
priorityName: item.priority?.name ?? null,
priorityCwId: item.priority?.id ?? null,
ratingName: item.rating?.name ?? null,
ratingCwId: item.rating?.id ?? null,
source: item.source ?? null,
campaignName: item.campaign?.name ?? null,
campaignCwId: item.campaign?.id ?? null,
primarySalesRepName: item.primarySalesRep?.name ?? null,
primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
primarySalesRepCwId: item.primarySalesRep?.id ?? null,
secondarySalesRepName: item.secondarySalesRep?.name ?? null,
secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null,
secondarySalesRepCwId: item.secondarySalesRep?.id ?? null,
companyCwId: item.company?.id ?? null,
companyName: item.company?.name ?? null,
contactCwId: item.contact?.id ?? null,
contactName: item.contact?.name ?? null,
siteCwId: item.site?.id ?? null,
siteName: item.site?.name ?? null,
customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0,
locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null,
departmentName: item.department?.name ?? null,
departmentCwId: item.department?.id ?? null,
expectedCloseDate: item.expectedCloseDate
? new Date(item.expectedCloseDate)
: null,
pipelineChangeDate: item.pipelineChangeDate
? new Date(item.pipelineChangeDate)
: null,
dateBecameLead: item.dateBecameLead
? new Date(item.dateBecameLead)
: null,
closedDate: item.closedDate ? new Date(item.closedDate) : null,
closedFlag: item.closedFlag ?? false,
closedByName: item.closedBy?.name ?? null,
closedByCwId: item.closedBy?.id ?? null,
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
};
}
/**
* To JSON
*
* Serializes the opportunity into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
notes: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
stage: this.stageCwId
? { id: this.stageCwId, name: this.stageName }
: null,
status: this.statusCwId
? { id: this.statusCwId, name: this.statusName }
: null,
priority: this.priorityCwId
? { id: this.priorityCwId, name: this.priorityName }
: null,
rating: this.ratingCwId
? { id: this.ratingCwId, name: this.ratingName }
: null,
source: this.source,
campaign: this.campaignCwId
? { id: this.campaignCwId, name: this.campaignName }
: null,
primarySalesRep: this.primarySalesRepCwId
? {
id: this.primarySalesRepCwId,
identifier: this.primarySalesRepIdentifier,
name: this.primarySalesRepName,
}
: null,
secondarySalesRep: this.secondarySalesRepCwId
? {
id: this.secondarySalesRepCwId,
identifier: this.secondarySalesRepIdentifier,
name: this.secondarySalesRepName,
}
: null,
company: this.companyCwId
? { id: this.companyCwId, name: this.companyName }
: null,
contact: this.contactCwId
? { id: this.contactCwId, name: this.contactName }
: null,
site: this.siteCwId ? { id: this.siteCwId, name: this.siteName } : null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
location: this.locationCwId
? { id: this.locationCwId, name: this.locationName }
: null,
department: this.departmentCwId
? { id: this.departmentCwId, name: this.departmentName }
: null,
expectedCloseDate: this.expectedCloseDate,
pipelineChangeDate: this.pipelineChangeDate,
dateBecameLead: this.dateBecameLead,
closedDate: this.closedDate,
closedFlag: this.closedFlag,
closedBy: this.closedByCwId
? { id: this.closedByCwId, name: this.closedByName }
: null,
companyId: this.companyId,
cwLastUpdated: this.cwLastUpdated,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+50 -1
View File
@@ -178,6 +178,46 @@ export default class UserController {
return decoded.permissions;
}
/**
* Read Role Permissions
*
* Verifies and decodes a role permissions JWT and returns the permission nodes.
* Returns an empty array if verification fails.
*
* @param role - Role record containing the signed permissions token
* @returns {string[]} The role permission nodes
*/
private _readRolePermissions(role: Role): string[] {
try {
const decoded = jwt.verify(role.permissions, permissionsPrivateKey, {
algorithms: ["RS256"],
issuer: "roles",
subject: role.id,
}) as DecodedPermissionsBlock;
return decoded.permissions;
} catch {
return [];
}
}
/**
* Read All Permissions
*
* Aggregates the user's direct permissions and all permissions from their assigned roles
* into a single deduplicated array.
*
* @returns {Promise<string[]>} Combined array of all permission nodes
*/
public async readAllPermissions(): Promise<string[]> {
const directPermissions = this.readPermissions();
const rolePermissions = this._roles
.map((role) => this._readRolePermissions(role))
.flatMap((permissions) => permissions);
return [...new Set([...directPermissions, ...rolePermissions])];
}
/**
* Fetch Roles
*
@@ -262,7 +302,16 @@ export default class UserController {
: this._roles.size > 0
? this._roles.map((v) => v.moniker)
: undefined,
permissions: opts?.safeReturn ? undefined : this.readPermissions(),
permissions: opts?.safeReturn
? undefined
: (() => {
const directPermissions = this.readPermissions();
const rolePermissions = this._roles
.map((role) => this._readRolePermissions(role))
.flatMap((permissions) => permissions);
return [...new Set([...directPermissions, ...rolePermissions])];
})(),
login: opts?.safeReturn ? undefined : this.login,
email: opts?.safeReturn ? undefined : this.email,
image: this.image,