fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivityCustomField,
|
||||
CWPatchOperation,
|
||||
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
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Activity entity.
|
||||
* Activities are not persisted locally — all data is sourced directly
|
||||
* from the ConnectWise API.
|
||||
*/
|
||||
export class ActivityController {
|
||||
public readonly cwActivityId: number;
|
||||
public name: string;
|
||||
public notes: string | null;
|
||||
|
||||
public typeName: string | null;
|
||||
public typeCwId: number | null;
|
||||
public statusName: string | null;
|
||||
public statusCwId: number | null;
|
||||
|
||||
public companyCwId: number | null;
|
||||
public companyName: string | null;
|
||||
public companyIdentifier: string | null;
|
||||
public contactCwId: number | null;
|
||||
public contactName: string | null;
|
||||
|
||||
public phoneNumber: string | null;
|
||||
public email: string | null;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
public ticketCwId: number | null;
|
||||
public ticketName: string | null;
|
||||
public agreementCwId: number | null;
|
||||
public agreementName: string | null;
|
||||
public campaignCwId: number | null;
|
||||
public campaignName: string | null;
|
||||
|
||||
public assignToCwId: number | null;
|
||||
public assignToName: string | null;
|
||||
public assignToIdentifier: string | null;
|
||||
|
||||
public scheduleStatusCwId: number | null;
|
||||
public scheduleStatusName: string | null;
|
||||
public reminderCwId: number | null;
|
||||
public reminderName: string | null;
|
||||
public whereCwId: number | null;
|
||||
public whereName: string | null;
|
||||
|
||||
public dateStart: Date | null;
|
||||
public dateEnd: Date | null;
|
||||
public notifyFlag: boolean;
|
||||
|
||||
public currencyCwId: number | null;
|
||||
public currencyName: string | null;
|
||||
|
||||
public mobileGuid: string | null;
|
||||
public customFields: CWActivityCustomField[];
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwDateEntered: Date | null;
|
||||
public cwEnteredBy: string | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
constructor(data: CWActivity) {
|
||||
this.cwActivityId = data.id;
|
||||
this.name = data.name;
|
||||
this.notes = data.notes ?? null;
|
||||
|
||||
this.typeName = data.type?.name ?? null;
|
||||
this.typeCwId = data.type?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
|
||||
this.companyCwId = data.company?.id ?? null;
|
||||
this.companyName = data.company?.name ?? null;
|
||||
this.companyIdentifier = data.company?.identifier ?? null;
|
||||
this.contactCwId = data.contact?.id ?? null;
|
||||
this.contactName = data.contact?.name ?? null;
|
||||
|
||||
this.phoneNumber = data.phoneNumber ?? null;
|
||||
this.email = data.email ?? null;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
this.ticketCwId = data.ticket?.id ?? null;
|
||||
this.ticketName = data.ticket?.name ?? null;
|
||||
this.agreementCwId = data.agreement?.id ?? null;
|
||||
this.agreementName = data.agreement?.name ?? null;
|
||||
this.campaignCwId = data.campaign?.id ?? null;
|
||||
this.campaignName = data.campaign?.name ?? null;
|
||||
|
||||
this.assignToCwId = data.assignTo?.id ?? null;
|
||||
this.assignToName = data.assignTo?.name ?? null;
|
||||
this.assignToIdentifier = data.assignTo?.identifier ?? null;
|
||||
|
||||
this.scheduleStatusCwId = data.scheduleStatus?.id ?? null;
|
||||
this.scheduleStatusName = data.scheduleStatus?.name ?? null;
|
||||
this.reminderCwId = data.reminder?.id ?? null;
|
||||
this.reminderName = data.reminder?.name ?? null;
|
||||
this.whereCwId = data.where?.id ?? null;
|
||||
this.whereName = data.where?.name ?? null;
|
||||
|
||||
this.dateStart = data.dateStart ? new Date(data.dateStart) : null;
|
||||
this.dateEnd = data.dateEnd ? new Date(data.dateEnd) : null;
|
||||
this.notifyFlag = data.notifyFlag ?? false;
|
||||
|
||||
this.currencyCwId = data.currency?.id ?? null;
|
||||
this.currencyName = data.currency?.name ?? null;
|
||||
|
||||
this.mobileGuid = data.mobileGuid ?? null;
|
||||
this.customFields = data.customFields ?? [];
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwDateEntered = data._info?.dateEntered
|
||||
? new Date(data._info.dateEntered)
|
||||
: null;
|
||||
this.cwEnteredBy = data._info?.enteredBy ?? null;
|
||||
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
|
||||
*
|
||||
* Applies JSON Patch operations to this activity in ConnectWise
|
||||
* and returns a new controller with the updated data.
|
||||
*/
|
||||
public async update(
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<ActivityController> {
|
||||
const updated = await activityCw.update(this.cwActivityId, operations);
|
||||
return new ActivityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from ConnectWise
|
||||
*
|
||||
* Deletes this activity in ConnectWise.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
await activityCw.delete(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Activity (static factory)
|
||||
*
|
||||
* Creates a new activity in ConnectWise and returns a controller instance.
|
||||
*/
|
||||
public static async create(
|
||||
data: CWCreateActivity,
|
||||
): Promise<ActivityController> {
|
||||
const created = await activityCw.create(data);
|
||||
return new ActivityController(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the activity into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
cwActivityId: this.cwActivityId,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
company: this.companyCwId
|
||||
? {
|
||||
id: this.companyCwId,
|
||||
identifier: this.companyIdentifier,
|
||||
name: this.companyName,
|
||||
}
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
phoneNumber: this.phoneNumber,
|
||||
email: this.email,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
ticket: this.ticketCwId
|
||||
? { id: this.ticketCwId, name: this.ticketName }
|
||||
: null,
|
||||
agreement: this.agreementCwId
|
||||
? { id: this.agreementCwId, name: this.agreementName }
|
||||
: null,
|
||||
campaign: this.campaignCwId
|
||||
? { id: this.campaignCwId, name: this.campaignName }
|
||||
: null,
|
||||
assignTo: this.assignToCwId
|
||||
? {
|
||||
id: this.assignToCwId,
|
||||
identifier: this.assignToIdentifier,
|
||||
name: this.assignToName,
|
||||
}
|
||||
: null,
|
||||
scheduleStatus: this.scheduleStatusCwId
|
||||
? { id: this.scheduleStatusCwId, name: this.scheduleStatusName }
|
||||
: null,
|
||||
reminder: this.reminderCwId
|
||||
? { id: this.reminderCwId, name: this.reminderName }
|
||||
: null,
|
||||
where: this.whereCwId
|
||||
? { id: this.whereCwId, name: this.whereName }
|
||||
: null,
|
||||
dateStart: this.dateStart,
|
||||
dateEnd: this.dateEnd,
|
||||
notifyFlag: this.notifyFlag,
|
||||
currency: this.currencyCwId
|
||||
? { id: this.currencyCwId, name: this.currencyName }
|
||||
: null,
|
||||
mobileGuid: this.mobileGuid,
|
||||
customFields: this.customFields,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwDateEntered: this.cwDateEntered,
|
||||
cwEnteredBy: this.cwEnteredBy,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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 category: string | null;
|
||||
public categoryCwId: number | null;
|
||||
public subcategory: string | null;
|
||||
public subcategoryCwId: number | 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.category = itemData.category;
|
||||
this.categoryCwId = itemData.categoryCwId;
|
||||
this.subcategory = itemData.subcategory;
|
||||
this.subcategoryCwId = itemData.subcategoryCwId;
|
||||
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,
|
||||
category: this.category,
|
||||
categoryCwId: this.categoryCwId,
|
||||
subcategory: this.subcategory,
|
||||
subcategoryCwId: this.subcategoryCwId,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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 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.
|
||||
*/
|
||||
export class CompanyController {
|
||||
public readonly id: string;
|
||||
public name: string;
|
||||
public readonly cw_Identifier: string;
|
||||
public readonly cw_CompanyId: number;
|
||||
public cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact | null;
|
||||
allContacts: Contact[];
|
||||
};
|
||||
|
||||
constructor(companyData: Company, cwData?: typeof this.cw_Data) {
|
||||
this.id = companyData.id;
|
||||
this.name = companyData.name;
|
||||
this.cw_Identifier = companyData.cw_Identifier;
|
||||
this.cw_CompanyId = companyData.cw_CompanyId;
|
||||
this.cw_Data = cwData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate CW Data
|
||||
*
|
||||
* Fetches and populates the full ConnectWise company data
|
||||
* (company, default contact, all contacts) if not already loaded.
|
||||
*
|
||||
* @returns {ThisType}
|
||||
*/
|
||||
public async hydrateCwData() {
|
||||
if (this.cw_Data) return this;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Internal Company Data from ConnectWise
|
||||
*
|
||||
* This method fetches the latest company data from ConnectWise and updates
|
||||
* the internal company information accordingly.
|
||||
*
|
||||
* @returns {ThisType} - Updated Controller
|
||||
*/
|
||||
public async refreshFromCW() {
|
||||
const data = await updateCwInternalCompany(this.cw_CompanyId);
|
||||
|
||||
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}
|
||||
*/
|
||||
public async fetchConfigurations() {
|
||||
const data = await fetchCompanyConfigurations(this.cw_CompanyId);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Sites
|
||||
*
|
||||
* Retrieves all sites for this company from ConnectWise
|
||||
* and returns them as serialized site objects.
|
||||
*/
|
||||
public async fetchSites() {
|
||||
const sites = await fetchCompanySites(this.cw_CompanyId);
|
||||
return sites.map(serializeCwSite);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public async fetchSite(cwSiteId: number) {
|
||||
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
|
||||
return serializeCwSite(site);
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeAddress: boolean;
|
||||
includePrimaryContact: boolean;
|
||||
includeAllContacts?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: this.id,
|
||||
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
|
||||
);
|
||||
})(),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credential,
|
||||
CredentialType,
|
||||
Company,
|
||||
SecureValue,
|
||||
} from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { fieldValidator } from "../modules/credentials/fieldValidator";
|
||||
import {
|
||||
CredentialField,
|
||||
CredentialTypeField,
|
||||
ValueType,
|
||||
} from "../modules/credentials/credentialTypeDefs";
|
||||
import { generateSecureValue } from "../modules/credentials/generateSecureValue";
|
||||
import { readSecureValue } from "../modules/credentials/readSecureValue";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* Credential Controller
|
||||
*
|
||||
* This class manages credential data, including field validation,
|
||||
* secure value storage/retrieval, and credential updates.
|
||||
*/
|
||||
export class CredentialController {
|
||||
public readonly id: string;
|
||||
public name: string;
|
||||
public notes: string | null;
|
||||
public readonly typeId: string;
|
||||
public readonly companyId: string;
|
||||
public readonly subCredentialOfId: string | null;
|
||||
public fields: any;
|
||||
|
||||
private _type: CredentialType;
|
||||
private _company: Company;
|
||||
private _secureValues: SecureValue[];
|
||||
private _subCredentials: CredentialController[];
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(
|
||||
credentialData: Credential & {
|
||||
type: CredentialType;
|
||||
company: Company;
|
||||
securevalues: SecureValue[];
|
||||
subCredentials?: (Credential & {
|
||||
type: CredentialType;
|
||||
company: Company;
|
||||
securevalues: SecureValue[];
|
||||
})[];
|
||||
},
|
||||
) {
|
||||
this.id = credentialData.id;
|
||||
this.name = credentialData.name;
|
||||
this.notes = credentialData.notes;
|
||||
this.typeId = credentialData.typeId;
|
||||
this.companyId = credentialData.companyId;
|
||||
this.subCredentialOfId = credentialData.subCredentialOfId;
|
||||
this._type = credentialData.type;
|
||||
this._company = credentialData.company;
|
||||
this._secureValues = credentialData.securevalues;
|
||||
this._subCredentials = (credentialData.subCredentials ?? []).map(
|
||||
(sc) => new CredentialController(sc),
|
||||
);
|
||||
this.fields = this._buildFields(credentialData);
|
||||
this.createdAt = credentialData.createdAt;
|
||||
this.updatedAt = credentialData.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Fields
|
||||
*
|
||||
* Maps raw credential data into a structured fields array.
|
||||
* - Regular credentials: maps through the type's field definitions.
|
||||
* - Multi-credential fields: returns sub-credential references and subField definitions.
|
||||
* - Sub-credentials: returns raw field data (validated against subFields, not the type's top-level fields).
|
||||
*/
|
||||
private _buildFields(credentialData: Credential) {
|
||||
const raw = credentialData.fields as Record<string, any>;
|
||||
const typeFields = this._type.fields as any as CredentialTypeField[];
|
||||
|
||||
// Sub-credentials: their fields don't match the type's top-level definitions,
|
||||
// so we return a simple id/value list built from raw JSON + secure values.
|
||||
if (credentialData.subCredentialOfId) {
|
||||
const result: any[] = [];
|
||||
|
||||
// Collect field IDs that have secure values
|
||||
const secureFieldIds = new Set(this._secureValues.map((sv) => sv.name));
|
||||
|
||||
// Non-secure fields from JSON
|
||||
Object.entries(raw).forEach(([fieldId, value]) => {
|
||||
if (!secureFieldIds.has(fieldId)) {
|
||||
result.push({ id: fieldId, value, secure: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Secure value references
|
||||
this._secureValues.forEach((sv) => {
|
||||
result.push({ id: sv.name, value: `secure-${sv.id}`, secure: true });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Regular (parent) credential: map through type field definitions
|
||||
return typeFields.map((f: any) => {
|
||||
if (f.valueType === ValueType.MULTI_CREDENTIAL) {
|
||||
const subCredIds: string[] = raw[f.id] ?? [];
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
secure: false,
|
||||
required: f.required,
|
||||
valueType: f.valueType,
|
||||
subFields: f.subFields ?? [],
|
||||
subCredentialIds: subCredIds,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
secure: f.secure,
|
||||
required: f.required,
|
||||
valueType: f.valueType as ValueType,
|
||||
value: f.secure
|
||||
? `secure-${this._secureValues.find((sv) => sv.name === f.id)?.id}`
|
||||
: raw[f.id],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Internal Values
|
||||
*
|
||||
* Internal method to update all internal values when we query the database.
|
||||
* This keeps everything up-to-date even when we pass around the credential controller.
|
||||
*
|
||||
* @param credentialData - Credential object from Prisma
|
||||
*/
|
||||
private _updateInternalValues(credentialData: Credential) {
|
||||
this.name = credentialData.name;
|
||||
this.notes = credentialData.notes;
|
||||
this.fields = credentialData.fields;
|
||||
this.updatedAt = credentialData.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and Update Fields
|
||||
*
|
||||
* This method validates the submitted fields against the credential type's
|
||||
* acceptable fields, then updates the credential in the database.
|
||||
* Secure fields are encrypted and stored in the SecureValue table.
|
||||
*
|
||||
* @param fields - The fields to validate and update
|
||||
* @returns {Promise<CredentialController>} - The updated credential controller
|
||||
*/
|
||||
async validateAndUpdateFields(
|
||||
fields: CredentialField[],
|
||||
): Promise<CredentialController> {
|
||||
// Get acceptable fields from the credential type
|
||||
const acceptableFields = this._type.fields as any as CredentialTypeField[];
|
||||
// Validate the fields
|
||||
const validatedFields = await fieldValidator(fields, acceptableFields);
|
||||
|
||||
// Separate secure and non-secure fields
|
||||
const secureFields = validatedFields.filter((f) => f.secure);
|
||||
const nonSecureFields = validatedFields.filter((f) => !f.secure);
|
||||
|
||||
// Process secure fields - encrypt and store in SecureValue table
|
||||
await Promise.all(
|
||||
secureFields.map(async (field) => {
|
||||
const { encrypted, hash } = generateSecureValue(field.value);
|
||||
|
||||
// Check if a secure value already exists for this field
|
||||
const existingSecureValue = await prisma.secureValue.findFirst({
|
||||
where: {
|
||||
credentialId: this.id,
|
||||
name: field.fieldId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSecureValue) {
|
||||
// Update existing secure value
|
||||
await prisma.secureValue.update({
|
||||
where: { id: existingSecureValue.id },
|
||||
data: {
|
||||
content: encrypted,
|
||||
hash,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new secure value
|
||||
await prisma.secureValue.create({
|
||||
data: {
|
||||
name: field.fieldId,
|
||||
content: encrypted,
|
||||
hash,
|
||||
credentialId: this.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Build fields object for non-secure fields
|
||||
const fieldsObject: Record<string, any> = {};
|
||||
nonSecureFields.forEach((field) => {
|
||||
fieldsObject[field.fieldId] = field.value;
|
||||
});
|
||||
|
||||
// Preserve multi-credential field values (sub-credential ID arrays)
|
||||
const currentFields = (await prisma.credential.findFirst({
|
||||
where: { id: this.id },
|
||||
select: { fields: true },
|
||||
}))!.fields as Record<string, any>;
|
||||
|
||||
const typeFields = this._type.fields as any as CredentialTypeField[];
|
||||
typeFields.forEach((f) => {
|
||||
if (f.valueType === ValueType.MULTI_CREDENTIAL && currentFields[f.id]) {
|
||||
fieldsObject[f.id] = currentFields[f.id];
|
||||
}
|
||||
});
|
||||
|
||||
// Update the credential with non-secure fields
|
||||
const updatedCredential = await prisma.credential.update({
|
||||
where: { id: this.id },
|
||||
data: {
|
||||
fields: fieldsObject,
|
||||
},
|
||||
});
|
||||
|
||||
this._updateInternalValues(updatedCredential);
|
||||
|
||||
// Refresh secure values
|
||||
const secureValues = await prisma.secureValue.findMany({
|
||||
where: { credentialId: this.id },
|
||||
});
|
||||
this._secureValues = secureValues;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch All Field Values
|
||||
*
|
||||
* Returns all field values (both secure and non-secure) for this credential.
|
||||
* Secure field values are NOT decrypted - use `readSecureFieldValue` for that.
|
||||
*
|
||||
* @returns {Promise<CredentialField[]>} - Array of all fields with their encrypted values
|
||||
*/
|
||||
async fetchAllFieldValues(): Promise<CredentialField[]> {
|
||||
const fields: CredentialField[] = [];
|
||||
|
||||
// Add non-secure fields from the fields JSON
|
||||
const nonSecureFields = this.fields as Record<string, any>;
|
||||
Object.entries(nonSecureFields || {}).forEach(([fieldId, value]) => {
|
||||
fields.push({
|
||||
fieldId,
|
||||
value: value as string,
|
||||
});
|
||||
});
|
||||
|
||||
// Add secure fields from SecureValue table (encrypted)
|
||||
this._secureValues.forEach((secureValue) => {
|
||||
fields.push({
|
||||
fieldId: secureValue.name,
|
||||
value: secureValue.content, // Encrypted value
|
||||
});
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Secure Field Value
|
||||
*
|
||||
* Decrypts and returns the value of a specific secure field.
|
||||
*
|
||||
* @param fieldId - The field ID to read
|
||||
* @returns {Promise<string>} - The decrypted field value
|
||||
*/
|
||||
async readSecureFieldValue(fieldId: string): Promise<string> {
|
||||
const secureValue = this._secureValues.find((sv) => sv.name === fieldId);
|
||||
|
||||
if (!secureValue) {
|
||||
throw new GenericError({
|
||||
message: `Secure field not found: ${fieldId}`,
|
||||
name: "SecureFieldNotFound",
|
||||
cause: `No secure value exists for field '${fieldId}' in credential '${this.id}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Decrypt the value
|
||||
const decryptedValue = readSecureValue(
|
||||
secureValue.content,
|
||||
secureValue.hash,
|
||||
);
|
||||
|
||||
return decryptedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read All Secure Values
|
||||
*
|
||||
* Decrypts and returns all secure field values for this credential.
|
||||
*
|
||||
* @returns {Promise<Record<string, string>>} - Object mapping field IDs to decrypted values
|
||||
*/
|
||||
async readAllSecureValues(): Promise<Record<string, string>> {
|
||||
const secureValues: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
this._secureValues.map(async (secureValue) => {
|
||||
const decryptedValue = readSecureValue(
|
||||
secureValue.content,
|
||||
secureValue.hash,
|
||||
);
|
||||
secureValues[secureValue.name] = decryptedValue;
|
||||
}),
|
||||
);
|
||||
|
||||
return secureValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Credential
|
||||
*
|
||||
* Update the credential name or other basic properties.
|
||||
*
|
||||
* @param data - Partial credential data to update
|
||||
* @returns {Promise<CredentialController>} - The updated credential controller
|
||||
*/
|
||||
async update(
|
||||
data: Partial<Pick<Credential, "name" | "notes">>,
|
||||
): Promise<CredentialController> {
|
||||
const pData = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.parse(data);
|
||||
|
||||
const updatedCredential = await prisma.credential.update({
|
||||
where: { id: this.id },
|
||||
data: pData,
|
||||
});
|
||||
|
||||
this._updateInternalValues(updatedCredential);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Credential Type
|
||||
*
|
||||
* Returns the credential type information.
|
||||
*
|
||||
* @returns {CredentialType} - The credential type
|
||||
*/
|
||||
getType(): CredentialType {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Company
|
||||
*
|
||||
* Returns the company this credential belongs to.
|
||||
*
|
||||
* @returns {Company} - The company
|
||||
*/
|
||||
getCompany(): Company {
|
||||
return this._company;
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Create an object that can be safely returned to the user.
|
||||
* Secure values are not included by default.
|
||||
*
|
||||
* @param opts - Options to change the output
|
||||
* @returns - An object that is JSON friendly
|
||||
*/
|
||||
toJson(opts?: { includeSecureValues?: boolean }): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
typeId: this.typeId,
|
||||
companyId: this.companyId,
|
||||
subCredentialOfId: this.subCredentialOfId ?? undefined,
|
||||
fields: this.fields,
|
||||
type: {
|
||||
id: this._type.id,
|
||||
name: this._type.name,
|
||||
fields: this._type.fields,
|
||||
permissionScope: this._type.permissionScope,
|
||||
},
|
||||
company: {
|
||||
id: this._company.id,
|
||||
name: this._company.name,
|
||||
},
|
||||
subCredentials:
|
||||
this._subCredentials.length > 0
|
||||
? this._subCredentials.map((sc) => sc.toJson(opts))
|
||||
: undefined,
|
||||
secureFieldIds: opts?.includeSecureValues
|
||||
? this._secureValues.map((sv) => sv.name)
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { z } from "zod";
|
||||
import { CredentialType, Credential } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { CredentialTypeField } from "../modules/credentials/credentialTypeDefs";
|
||||
import { CredentialController } from "./CredentialController";
|
||||
|
||||
/**
|
||||
* Credential Type Controller
|
||||
*
|
||||
* This class manages credential type data, including field definitions,
|
||||
* permission scopes, and associated credentials.
|
||||
*/
|
||||
export class CredentialTypeController {
|
||||
public readonly id: string;
|
||||
public name: string;
|
||||
public permissionScope: string;
|
||||
public icon: string | null;
|
||||
public fields: CredentialTypeField[];
|
||||
|
||||
private _credentials: Credential[];
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(
|
||||
credentialTypeData: CredentialType & {
|
||||
credentials: Credential[];
|
||||
},
|
||||
) {
|
||||
this.id = credentialTypeData.id;
|
||||
this.name = credentialTypeData.name;
|
||||
this.permissionScope = credentialTypeData.permissionScope;
|
||||
this.icon = credentialTypeData.icon;
|
||||
this.fields = credentialTypeData.fields! as any as CredentialTypeField[];
|
||||
this._credentials = credentialTypeData.credentials;
|
||||
this.createdAt = credentialTypeData.createdAt;
|
||||
this.updatedAt = credentialTypeData.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Internal Values
|
||||
*
|
||||
* Internal method to update all internal values when we query the database.
|
||||
* This keeps everything up-to-date even when we pass around the credential type controller.
|
||||
*
|
||||
* @param credentialTypeData - CredentialType object from Prisma
|
||||
*/
|
||||
private _updateInternalValues(credentialTypeData: CredentialType) {
|
||||
this.name = credentialTypeData.name;
|
||||
this.permissionScope = credentialTypeData.permissionScope;
|
||||
this.icon = credentialTypeData.icon;
|
||||
this.fields = credentialTypeData.fields! as any as CredentialTypeField[];
|
||||
this.updatedAt = credentialTypeData.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Credential Type
|
||||
*
|
||||
* Update the credential type's name, permission scope, icon, or fields.
|
||||
*
|
||||
* @param data - Partial credential type data to update
|
||||
* @returns {Promise<CredentialTypeController>} - The updated credential type controller
|
||||
*/
|
||||
async update(
|
||||
data: Partial<
|
||||
Pick<CredentialType, "name" | "permissionScope" | "icon"> & {
|
||||
fields: CredentialTypeField[];
|
||||
}
|
||||
>,
|
||||
): Promise<CredentialTypeController> {
|
||||
const pData = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
permissionScope: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
fields: z.array(z.any()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.parse(data);
|
||||
|
||||
const updatedCredentialType = await prisma.credentialType.update({
|
||||
where: { id: this.id },
|
||||
data: pData,
|
||||
});
|
||||
|
||||
this._updateInternalValues(updatedCredentialType);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Field Definition
|
||||
*
|
||||
* Get the definition for a specific field by its ID.
|
||||
*
|
||||
* @param fieldId - The field ID to look up
|
||||
* @returns {CredentialTypeField | undefined} - The field definition or undefined
|
||||
*/
|
||||
getFieldDefinition(fieldId: string): CredentialTypeField | undefined {
|
||||
return this.fields.find((f) => f.id === fieldId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Required Fields
|
||||
*
|
||||
* Returns all fields that are marked as required.
|
||||
*
|
||||
* @returns {CredentialTypeField[]} - Array of required fields
|
||||
*/
|
||||
getRequiredFields(): CredentialTypeField[] {
|
||||
return this.fields.filter((f) => f.required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Secure Fields
|
||||
*
|
||||
* Returns all fields that should be stored securely (encrypted).
|
||||
*
|
||||
* @returns {CredentialTypeField[]} - Array of secure fields
|
||||
*/
|
||||
getSecureFields(): CredentialTypeField[] {
|
||||
return this.fields.filter((f) => f.secure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Credentials
|
||||
*
|
||||
* Fetch all credentials that use this credential type.
|
||||
*
|
||||
* @returns {Promise<CredentialController[]>} - Array of credential controllers
|
||||
*/
|
||||
async fetchCredentials(): Promise<CredentialController[]> {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: { typeId: this.id },
|
||||
include: {
|
||||
type: true,
|
||||
company: true,
|
||||
securevalues: true,
|
||||
},
|
||||
});
|
||||
|
||||
return credentials.map((cred) => new CredentialController(cred));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count Credentials
|
||||
*
|
||||
* Count how many credentials use this credential type.
|
||||
*
|
||||
* @returns {number} - Number of credentials using this type
|
||||
*/
|
||||
countCredentials(): number {
|
||||
return this._credentials.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Create an object that can be safely returned to the user.
|
||||
*
|
||||
* @param opts - Options to change the output
|
||||
* @returns - An object that is JSON friendly
|
||||
*/
|
||||
toJson(opts?: { includeCredentialCount?: boolean }) {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
permissionScope: this.permissionScope,
|
||||
icon: this.icon,
|
||||
fields: this.fields,
|
||||
credentialCount: opts?.includeCredentialCount
|
||||
? this._credentials.length
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { CwMember } from "../../generated/prisma/client";
|
||||
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
|
||||
|
||||
/**
|
||||
* CW Member Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Member entity,
|
||||
* providing access to member data and serialization for the API.
|
||||
*/
|
||||
export class CwMemberController {
|
||||
public readonly id: string;
|
||||
public readonly cwMemberId: number;
|
||||
public readonly identifier: string;
|
||||
public firstName: string;
|
||||
public lastName: string;
|
||||
public officeEmail: string | null;
|
||||
public inactiveFlag: boolean;
|
||||
public apiKey: string | null;
|
||||
public cwLastUpdated: Date | null;
|
||||
public readonly createdAt: Date;
|
||||
public readonly updatedAt: Date;
|
||||
|
||||
constructor(data: CwMember) {
|
||||
this.id = data.id;
|
||||
this.cwMemberId = data.cwMemberId;
|
||||
this.identifier = data.identifier;
|
||||
this.firstName = data.firstName;
|
||||
this.lastName = data.lastName;
|
||||
this.officeEmail = data.officeEmail;
|
||||
this.inactiveFlag = data.inactiveFlag;
|
||||
this.apiKey = data.apiKey;
|
||||
this.cwLastUpdated = data.cwLastUpdated;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Name
|
||||
*
|
||||
* Returns the member's full name, falling back to the identifier.
|
||||
*/
|
||||
public get fullName(): string {
|
||||
const name = `${this.firstName} ${this.lastName}`.trim();
|
||||
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
|
||||
*
|
||||
* Serializes the member into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
cwMemberId: this.cwMemberId,
|
||||
identifier: this.identifier,
|
||||
firstName: this.firstName,
|
||||
lastName: this.lastName,
|
||||
fullName: this.fullName,
|
||||
officeEmail: this.officeEmail,
|
||||
inactiveFlag: this.inactiveFlag,
|
||||
apiKey: this.apiKey,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
|
||||
/**
|
||||
* Forecast Product Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Forecast Item (product/
|
||||
* revenue line item on an opportunity). Forecast products are not persisted
|
||||
* locally — all data is sourced directly from the ConnectWise API.
|
||||
*/
|
||||
export class ForecastProductController {
|
||||
public readonly cwForecastId: number;
|
||||
public forecastDescription: string;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
|
||||
public quantity: number;
|
||||
|
||||
public statusCwId: number | null;
|
||||
public statusName: string | null;
|
||||
|
||||
public catalogItemCwId: number | null;
|
||||
public catalogItemIdentifier: string | null;
|
||||
|
||||
public productDescription: string;
|
||||
public customerDescription: string | null;
|
||||
public productNarrative: string | null;
|
||||
public productClass: string;
|
||||
public forecastType: string;
|
||||
|
||||
public revenue: number;
|
||||
public cost: number;
|
||||
public margin: number;
|
||||
public percentage: number;
|
||||
|
||||
public includeFlag: boolean;
|
||||
public linkFlag: boolean;
|
||||
public recurringFlag: boolean;
|
||||
public taxableFlag: boolean;
|
||||
|
||||
public recurringRevenue: number;
|
||||
public recurringCost: number;
|
||||
public cycles: number;
|
||||
|
||||
public sequenceNumber: number;
|
||||
public subNumber: number;
|
||||
public quoteWerksQuantity: number;
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
// Cancellation data (from procurement products endpoint)
|
||||
public cancelledFlag: boolean;
|
||||
public quantityCancelled: number;
|
||||
public cancelledReason: string | null;
|
||||
public cancelledBy: number | null;
|
||||
public cancelledDate: Date | null;
|
||||
|
||||
// Internal inventory data (from local CatalogItem database)
|
||||
public onHand: number | null;
|
||||
public inStock: boolean | null;
|
||||
|
||||
constructor(data: CWForecastItem) {
|
||||
this.cwForecastId = data.id;
|
||||
this.forecastDescription = data.forecastDescription;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
|
||||
this.quantity = data.quantity;
|
||||
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
|
||||
this.catalogItemCwId = data.catalogItem?.id ?? null;
|
||||
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
|
||||
|
||||
this.productDescription = data.productDescription;
|
||||
this.customerDescription = data.customerDescription ?? null;
|
||||
this.productNarrative =
|
||||
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
|
||||
this.productClass = data.productClass;
|
||||
this.forecastType = data.forecastType;
|
||||
|
||||
this.revenue = data.revenue;
|
||||
this.cost = data.cost;
|
||||
this.margin = data.margin;
|
||||
this.percentage = data.percentage;
|
||||
|
||||
this.includeFlag = data.includeFlag ?? false;
|
||||
this.linkFlag = data.linkFlag ?? false;
|
||||
this.recurringFlag = data.recurringFlag ?? false;
|
||||
this.taxableFlag = data.taxableFlag ?? false;
|
||||
|
||||
this.recurringRevenue = data.recurringRevenue ?? 0;
|
||||
this.recurringCost = data.recurringCost ?? 0;
|
||||
this.cycles = data.cycles ?? 0;
|
||||
|
||||
this.sequenceNumber = data.sequenceNumber ?? 0;
|
||||
this.subNumber = data.subNumber ?? 0;
|
||||
this.quoteWerksQuantity = data.quoteWerksQuantity ?? 0;
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
|
||||
// Cancellation defaults — enriched later via applyCancellationData()
|
||||
this.cancelledFlag = false;
|
||||
this.quantityCancelled = 0;
|
||||
this.cancelledReason = null;
|
||||
this.cancelledBy = null;
|
||||
this.cancelledDate = null;
|
||||
|
||||
// Inventory defaults — enriched later via applyInventoryData()
|
||||
this.onHand = null;
|
||||
this.inStock = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Cancellation Data
|
||||
*
|
||||
* Enriches this forecast product with cancellation data from the
|
||||
* procurement products endpoint.
|
||||
*/
|
||||
/**
|
||||
* Apply Procurement Custom Fields
|
||||
*
|
||||
* Enriches this forecast product with custom field data from the
|
||||
* procurement products endpoint (the forecast endpoint does not
|
||||
* return customFields).
|
||||
*/
|
||||
public applyProcurementCustomFields(data: {
|
||||
customFields?: Array<{ id: number; value?: unknown }>;
|
||||
}): void {
|
||||
const narrative = data.customFields
|
||||
?.find((f) => f.id === 46)
|
||||
?.value?.toString();
|
||||
if (narrative) {
|
||||
this.productNarrative = narrative;
|
||||
}
|
||||
}
|
||||
|
||||
public applyCancellationData(data: {
|
||||
cancelledFlag?: boolean;
|
||||
quantityCancelled?: number;
|
||||
cancelledReason?: string;
|
||||
cancelledBy?: number;
|
||||
cancelledDate?: string;
|
||||
}): void {
|
||||
this.cancelledFlag = data.cancelledFlag ?? false;
|
||||
this.quantityCancelled = data.quantityCancelled ?? 0;
|
||||
this.cancelledReason = data.cancelledReason ?? null;
|
||||
this.cancelledBy = data.cancelledBy ?? null;
|
||||
this.cancelledDate = data.cancelledDate
|
||||
? new Date(data.cancelledDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Inventory Data
|
||||
*
|
||||
* Enriches this forecast product with internal inventory data from
|
||||
* the local CatalogItem database.
|
||||
*/
|
||||
public applyInventoryData(data: { onHand: number }): void {
|
||||
this.onHand = data.onHand;
|
||||
this.inStock = data.onHand > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profit
|
||||
*
|
||||
* Returns the calculated profit (revenue - cost).
|
||||
*/
|
||||
public get profit(): number {
|
||||
return this.revenue - this.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Quantity
|
||||
*
|
||||
* Returns the quantity adjusted for cancellations (minimum 0).
|
||||
*/
|
||||
public get effectiveQuantity(): number {
|
||||
if (this.cancellationType === "full") return 0;
|
||||
return Math.max(0, this.quantity - this.quantityCancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Revenue
|
||||
*
|
||||
* Returns the revenue adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveRevenue(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitPrice = this.revenue / this.quantity;
|
||||
return unitPrice * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Cost
|
||||
*
|
||||
* Returns the cost adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveCost(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitCost = this.cost / this.quantity;
|
||||
return unitCost * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled
|
||||
*
|
||||
* Returns true if the forecast item has been cancelled (fully or partially).
|
||||
*/
|
||||
public get cancelled(): boolean {
|
||||
return this.cancelledFlag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation Type
|
||||
*
|
||||
* Returns the type of cancellation:
|
||||
* - `"full"` — all units have been cancelled (`quantityCancelled >= quantity`)
|
||||
* - `"partial"` — some units cancelled but not all
|
||||
* - `null` — not cancelled
|
||||
*/
|
||||
public get cancellationType(): "full" | "partial" | null {
|
||||
if (!this.cancelledFlag || this.quantityCancelled <= 0) return null;
|
||||
return this.quantityCancelled >= this.quantity ? "full" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the forecast product into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.cwForecastId,
|
||||
forecastDescription: this.forecastDescription,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
quantity: this.quantity,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
cancelled: this.cancelled,
|
||||
cancellationType: this.cancellationType,
|
||||
quantityCancelled: this.quantityCancelled,
|
||||
cancelledReason: this.cancelledReason,
|
||||
cancelledDate: this.cancelledDate,
|
||||
catalogItem: this.catalogItemCwId
|
||||
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
|
||||
: null,
|
||||
productDescription: this.productDescription,
|
||||
customerDescription: this.customerDescription,
|
||||
productNarrative: this.productNarrative,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
effectiveQuantity: this.effectiveQuantity,
|
||||
effectiveRevenue: this.effectiveRevenue,
|
||||
effectiveCost: this.effectiveCost,
|
||||
percentage: this.percentage,
|
||||
includeFlag: this.includeFlag,
|
||||
linkFlag: this.linkFlag,
|
||||
recurringFlag: this.recurringFlag,
|
||||
taxableFlag: this.taxableFlag,
|
||||
recurringRevenue: this.recurringRevenue,
|
||||
recurringCost: this.recurringCost,
|
||||
cycles: this.cycles,
|
||||
sequenceNumber: this.sequenceNumber,
|
||||
subNumber: this.subNumber,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
onHand: this.onHand,
|
||||
inStock: this.inStock,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
GeneratedQuotes,
|
||||
Opportunity,
|
||||
Role,
|
||||
User,
|
||||
} from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { OpportunityController } from "./OpportunityController";
|
||||
import UserController from "./UserController";
|
||||
|
||||
export class GeneratedQuoteController {
|
||||
public readonly id: string;
|
||||
|
||||
public quoteRegenData: unknown;
|
||||
public quoteRegenParams: unknown;
|
||||
public quoteRegenHash: string;
|
||||
|
||||
public downloads: unknown[];
|
||||
|
||||
public quoteFile: Uint8Array;
|
||||
public quoteFileName: string;
|
||||
|
||||
public opportunityId: string;
|
||||
public createdById: string | null;
|
||||
|
||||
public createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
private _opportunity: OpportunityController | null;
|
||||
private _createdBy: UserController | null;
|
||||
|
||||
constructor(
|
||||
data: GeneratedQuotes & {
|
||||
opportunity?: Opportunity | null;
|
||||
createdBy?: (User & { roles: Role[] }) | null;
|
||||
},
|
||||
) {
|
||||
this.id = data.id;
|
||||
|
||||
this.quoteRegenData = data.quoteRegenData;
|
||||
this.quoteRegenParams = data.quoteRegenParams;
|
||||
this.quoteRegenHash = data.quoteRegenHash;
|
||||
|
||||
this.downloads = Array.isArray(data.downloads)
|
||||
? (data.downloads as unknown[])
|
||||
: [];
|
||||
|
||||
this.quoteFile = data.quoteFile;
|
||||
this.quoteFileName = data.quoteFileName;
|
||||
|
||||
this.opportunityId = data.opportunityId;
|
||||
this.createdById = data.createdById;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
|
||||
this._opportunity = data.opportunity
|
||||
? new OpportunityController(data.opportunity)
|
||||
: null;
|
||||
|
||||
this._createdBy = data.createdBy
|
||||
? new UserController(data.createdBy)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async fetchOpportunity(): Promise<OpportunityController | null> {
|
||||
if (this._opportunity) return this._opportunity;
|
||||
|
||||
const opportunity = await prisma.opportunity.findFirst({
|
||||
where: { id: this.opportunityId },
|
||||
});
|
||||
|
||||
if (!opportunity) return null;
|
||||
|
||||
this._opportunity = new OpportunityController(opportunity);
|
||||
return this._opportunity;
|
||||
}
|
||||
|
||||
public async fetchCreatedBy(): Promise<UserController | null> {
|
||||
if (this._createdBy) return this._createdBy;
|
||||
if (!this.createdById) return null;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: this.createdById },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
this._createdBy = new UserController(user);
|
||||
return this._createdBy;
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeFile?: boolean;
|
||||
encodeFileAsBase64?: boolean;
|
||||
includeRegenData?: boolean;
|
||||
includeRegenParams?: boolean;
|
||||
includeDownloads?: boolean;
|
||||
includeOpportunity?: boolean;
|
||||
includeCreatedBy?: boolean;
|
||||
}): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
quoteFileName: this.quoteFileName,
|
||||
quoteRegenHash: this.quoteRegenHash,
|
||||
opportunityId: this.opportunityId,
|
||||
createdById: this.createdById,
|
||||
downloads: opts?.includeDownloads ? this.downloads : undefined,
|
||||
quoteRegenData: opts?.includeRegenData ? this.quoteRegenData : undefined,
|
||||
quoteRegenParams: opts?.includeRegenParams
|
||||
? this.quoteRegenParams
|
||||
: undefined,
|
||||
quoteFile: !opts?.includeFile
|
||||
? undefined
|
||||
: opts?.encodeFileAsBase64
|
||||
? Buffer.from(this.quoteFile).toString("base64")
|
||||
: this.quoteFile,
|
||||
opportunity:
|
||||
opts?.includeOpportunity && this._opportunity
|
||||
? this._opportunity.toJson()
|
||||
: undefined,
|
||||
createdBy:
|
||||
opts?.includeCreatedBy && this._createdBy
|
||||
? this._createdBy.toJson()
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,351 @@
|
||||
import UserController from "./UserController";
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import jwt, { JsonWebTokenError } from "jsonwebtoken";
|
||||
import { permissionsPrivateKey, prisma } from "../constants";
|
||||
import PermissionsVerificationError from "../Errors/PermissionsVerificationError";
|
||||
import { mergeArrays } from "../modules/tools/mergeArrays";
|
||||
import { signPermissions } from "../modules/permission-utils/signPermissions";
|
||||
import { DecodedPermissionsBlock } from "../types/PermissionTypes";
|
||||
import { permissionValidator } from "../modules/permission-utils/permissionValidator";
|
||||
import { z } from "zod";
|
||||
import { roles } from "../managers/roles";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import RoleError from "../Errors/RoleError";
|
||||
import { Role, User } from "../../generated/prisma/client";
|
||||
import { events } from "../modules/globalEvents";
|
||||
|
||||
/**
|
||||
* Roles
|
||||
*
|
||||
* Roles are for adding onto a users permissions. They are not for defining a default users permissions.
|
||||
*
|
||||
* Roles have two forms of identifiers, titles and monikers. The title is something that can be capitalized and publicly displayed,
|
||||
* so if you make modifications, and you need to be able to see how they were able to do that without having explicit permission to
|
||||
* make the modifications, the title is what would be shown to you. The moniker is the human readable identifier. So in an api
|
||||
* response where you need to get the roles of a user, instead of being given a bunch of id's, you will be given a bunch of monikers
|
||||
* which are easy to read and easy to identify.
|
||||
*/
|
||||
export class RoleController {
|
||||
public readonly id: string;
|
||||
public title: string;
|
||||
public moniker: string; // e.g. admin, super_admin, moderator
|
||||
|
||||
private _permissionsToken: string;
|
||||
private _users: (User & { roles: Role[] })[];
|
||||
|
||||
/** Cached result of JWT verification — avoids repeated RSA verify calls. */
|
||||
private _cachedVerifiedPermissions: { permissions: string[] } | null = null;
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
public deleted: boolean = false;
|
||||
|
||||
constructor(roledata: Role & { users: (User & { roles: Role[] })[] }) {
|
||||
this.id = roledata.id;
|
||||
this.title = roledata.title;
|
||||
this.moniker = roledata.moniker;
|
||||
|
||||
this._permissionsToken = roledata.permissions;
|
||||
this._users = roledata.users;
|
||||
|
||||
this.createdAt = roledata.createdAt;
|
||||
this.updatedAt = roledata.updatedAt;
|
||||
}
|
||||
|
||||
private _signPermissions = signPermissions;
|
||||
|
||||
/**
|
||||
* Verify Permissions
|
||||
*
|
||||
* This method is vital to maintining the security of this system. This method is here to ensure
|
||||
* that the permissions object is authentic and signed by our system.
|
||||
*
|
||||
* @param permissionsToken - Signed permissions JWT
|
||||
* @returns - Verified object with permissions in it.
|
||||
*/
|
||||
private _verifyPermissions(permissionsToken: string) {
|
||||
// Return cached result if the token hasn't changed
|
||||
if (
|
||||
this._cachedVerifiedPermissions &&
|
||||
permissionsToken === this._permissionsToken
|
||||
) {
|
||||
return this._cachedVerifiedPermissions;
|
||||
}
|
||||
|
||||
let perms: DecodedPermissionsBlock;
|
||||
try {
|
||||
perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
|
||||
algorithms: ["RS256"],
|
||||
issuer: "roles",
|
||||
subject: this.id,
|
||||
}) as DecodedPermissionsBlock;
|
||||
} catch (err) {
|
||||
events.emit("role:permissions:verification_error", {
|
||||
currentSigned: this._permissionsToken,
|
||||
attemptedVerification: permissionsToken,
|
||||
err: err as Error,
|
||||
role: this,
|
||||
});
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
const result = perms as { permissions: string[] };
|
||||
// Cache only if verifying the current token
|
||||
if (permissionsToken === this._permissionsToken) {
|
||||
this._cachedVerifiedPermissions = result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Users
|
||||
*
|
||||
* This will get all the users that have this role and return it as a collection dictionary where the key is
|
||||
* the `id` of the user and the value is the users `UserController`.
|
||||
*
|
||||
* @returns {Collection<string, UserController>} - A collection of all the users that are assigned to this role
|
||||
*/
|
||||
public getUsers() {
|
||||
const collection = new Collection<string, UserController>();
|
||||
this._users.map((v) => collection.set(v.id, new UserController(v)));
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Permission
|
||||
*
|
||||
* Check to see if a role has a specified set of permissions.
|
||||
*
|
||||
* @param permission - The permission to check for
|
||||
* @returns {boolean} Does this role have the specified permission
|
||||
*/
|
||||
public checkPermission(permission: string): boolean {
|
||||
const permissions = this._verifyPermissions(this._permissionsToken);
|
||||
return permissionValidator(permission, permissions.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set permissions
|
||||
*
|
||||
* This will remove all existing permissions and replate them with the permissions defined in the params
|
||||
* of this method.
|
||||
*
|
||||
* @param permissions - Array of all Permissions
|
||||
* @returns {void}
|
||||
*/
|
||||
public async setPermissions(...permissions: string[]) {
|
||||
/*
|
||||
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
|
||||
are tampered with in any way, this bricks the further modification of the permissions
|
||||
*/
|
||||
const previous = this._verifyPermissions(this._permissionsToken);
|
||||
|
||||
const newPermissionsToken = this._signPermissions({
|
||||
issuer: "roles",
|
||||
subject: this.id,
|
||||
permissions,
|
||||
});
|
||||
const newRaw = await prisma.role.update({
|
||||
where: { id: this.id },
|
||||
data: { permissions: newPermissionsToken },
|
||||
});
|
||||
|
||||
events.emit("role:permissions:set", {
|
||||
current: permissions,
|
||||
currentSigned: newPermissionsToken,
|
||||
previous: previous.permissions,
|
||||
previousSigned: this._permissionsToken,
|
||||
role: this,
|
||||
});
|
||||
|
||||
this._permissionsToken = newPermissionsToken;
|
||||
this.updatedAt = newRaw.updatedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Permissions
|
||||
*
|
||||
* This will take the permissions provided in the params of the method and combine them into the pre-existing
|
||||
* permissions of this role.
|
||||
*
|
||||
* @param permissions - Array of added Permissions
|
||||
* @returns {void}
|
||||
*/
|
||||
public async addPermissions(...permissions: string[]) {
|
||||
/*
|
||||
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
|
||||
are tampered with in any way, this bricks the further modification of the permissions
|
||||
*/
|
||||
const previous = this._verifyPermissions(this._permissionsToken);
|
||||
|
||||
const newPermissionsToken = this._signPermissions({
|
||||
issuer: "roles",
|
||||
subject: this.id,
|
||||
permissions: mergeArrays(previous.permissions, permissions),
|
||||
});
|
||||
const newRaw = await prisma.role.update({
|
||||
where: { id: this.id },
|
||||
data: { permissions: newPermissionsToken },
|
||||
});
|
||||
|
||||
events.emit("role:permissions:added", {
|
||||
previous: previous.permissions,
|
||||
previousSigned: this._permissionsToken,
|
||||
added: permissions,
|
||||
currentSigned: newPermissionsToken,
|
||||
role: this,
|
||||
});
|
||||
|
||||
this._permissionsToken = newPermissionsToken;
|
||||
this.updatedAt = newRaw.updatedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Permissions
|
||||
*
|
||||
* This will take the permissions provided in the params of the method and remove them from the exisitng
|
||||
* permissions object for this role.
|
||||
*
|
||||
* @param permissions - Array of removed Permissions
|
||||
* @returns {void}
|
||||
*/
|
||||
public async removePermissions(...permissions: string[]) {
|
||||
/*
|
||||
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
|
||||
are tampered with in any way, this bricks the further modification of the permissions
|
||||
*/
|
||||
const previous = this._verifyPermissions(this._permissionsToken);
|
||||
|
||||
const newPermissionsToken = this._signPermissions({
|
||||
issuer: "roles",
|
||||
subject: this.id,
|
||||
permissions: previous.permissions.filter((v) => !permissions.includes(v)),
|
||||
});
|
||||
const newRaw = await prisma.role.update({
|
||||
where: { id: this.id },
|
||||
data: { permissions: newPermissionsToken },
|
||||
});
|
||||
|
||||
events.emit("role:permissions:removed", {
|
||||
previous: previous.permissions,
|
||||
previousSigned: this._permissionsToken,
|
||||
removed: permissions,
|
||||
currentSigned: newPermissionsToken,
|
||||
role: this,
|
||||
});
|
||||
|
||||
this._permissionsToken = newPermissionsToken;
|
||||
this.updatedAt = newRaw.updatedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Current Permissions
|
||||
*
|
||||
* Get all of the permissions for this role.
|
||||
* @returns {string[]} - Existing permissions in an array
|
||||
*/
|
||||
public getPermissions() {
|
||||
const permissions = this._verifyPermissions(this._permissionsToken!);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
public async update(
|
||||
data: Partial<{
|
||||
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"),
|
||||
),
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
data = schema.parse(data);
|
||||
|
||||
if (data.moniker) {
|
||||
const checkMoniker = await prisma.role.findFirst({
|
||||
where: { moniker: data.moniker },
|
||||
});
|
||||
|
||||
if (checkMoniker && checkMoniker.moniker !== this.moniker)
|
||||
throw new RoleError(
|
||||
"Moniker is already taken.",
|
||||
"Another role with this moniker already exists in the databse.",
|
||||
);
|
||||
}
|
||||
|
||||
const updatedRole = await prisma.role.update({
|
||||
where: { id: this.id },
|
||||
data: {
|
||||
...data,
|
||||
permissions: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.permissions) await this.setPermissions(...data.permissions);
|
||||
|
||||
events.emit("role:updated", { role: this, updateData: data });
|
||||
this.title = data.title ?? this.title;
|
||||
this.moniker = data.moniker ?? this.moniker;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Role
|
||||
*
|
||||
* @returns {Promise<RoleController>} The Remains of a deleted role.
|
||||
*/
|
||||
public async delete() {
|
||||
const deletedData = await prisma.role.delete({ where: { id: this.id } });
|
||||
|
||||
this.deleted = true;
|
||||
events.emit("role:deleted", this);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Create a JSON object that can be used in things like API responses.
|
||||
*
|
||||
* @param opts - Optional values to include in the response.
|
||||
* @returns - JSON-Friendly object.
|
||||
*/
|
||||
public toJson(opts?: { viewUsers?: boolean; viewPermissions?: boolean }) {
|
||||
let object = {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
moniker: this.moniker,
|
||||
permissions: opts?.viewPermissions
|
||||
? this._verifyPermissions(this._permissionsToken).permissions
|
||||
: undefined,
|
||||
users: opts?.viewUsers
|
||||
? this._users.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
login: v.login,
|
||||
roles: v.roles.map((r: any) => r.id),
|
||||
}))
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
|
||||
return object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import {
|
||||
prisma,
|
||||
refreshTokenDuration,
|
||||
accessTokenDuration,
|
||||
accessTokenPrivateKey,
|
||||
refreshTokenPrivateKey,
|
||||
} from "../constants";
|
||||
import SessionTokenError from "../Errors/SessionTokenError";
|
||||
import { events } from "../modules/globalEvents";
|
||||
import UserController from "./UserController";
|
||||
import { users } from "../managers/users";
|
||||
import { Session } from "../../generated/prisma/client";
|
||||
|
||||
export interface SessionPayloadObject {
|
||||
userID: string;
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
export interface SessionTokensObject {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export type DecodedSession = SessionPayloadObject & {
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session Controller
|
||||
*
|
||||
* This class is for create a controller that can manage, generate and refresh tokens, self terminate and self delete
|
||||
* all sessions. This also allows you to access all data about a session.
|
||||
*/
|
||||
export class SessionController {
|
||||
public readonly id: string;
|
||||
public readonly sessionKey: string;
|
||||
public readonly userId: string;
|
||||
public readonly expires: Date;
|
||||
public refreshedAt: Date | null;
|
||||
public invalidatedAt: Date | null;
|
||||
|
||||
public terminated: boolean = false;
|
||||
|
||||
private _refreshTokenGenrated: boolean;
|
||||
|
||||
constructor(sessionData: Session) {
|
||||
this.id = sessionData.id;
|
||||
this.sessionKey = sessionData.sessionKey;
|
||||
this.userId = sessionData.userId;
|
||||
this.expires = sessionData.expires;
|
||||
this.refreshedAt = sessionData.refreshedAt;
|
||||
this.invalidatedAt = sessionData.invalidatedAt;
|
||||
|
||||
this._refreshTokenGenrated = sessionData.refreshTokenGenerated;
|
||||
}
|
||||
|
||||
/** @ignore */
|
||||
private _generateAccessToken() {
|
||||
const payload: SessionPayloadObject = {
|
||||
sessionKey: this.sessionKey,
|
||||
userID: this.userId,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, accessTokenPrivateKey, {
|
||||
algorithm: "RS256",
|
||||
expiresIn: accessTokenDuration,
|
||||
});
|
||||
}
|
||||
|
||||
/** @ignore */
|
||||
private _generateRefreshToken() {
|
||||
const payload: SessionPayloadObject = {
|
||||
sessionKey: this.sessionKey,
|
||||
userID: this.userId,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, refreshTokenPrivateKey, {
|
||||
algorithm: "RS256",
|
||||
expiresIn: refreshTokenDuration,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the Session
|
||||
*
|
||||
* The purpose for this function is if you wanted to be able to listen for somebody using an invalid session,
|
||||
* you just have to invalidate it with this function and go from there.
|
||||
*
|
||||
* @returns {Promise<void>} - nothing
|
||||
*/
|
||||
public async invalidate() {
|
||||
const invalidationDate = new Date();
|
||||
|
||||
if (this.invalidatedAt)
|
||||
throw new Error("Session has already been invalidated.");
|
||||
|
||||
await prisma.session.update({
|
||||
data: { invalidatedAt: invalidationDate },
|
||||
where: { id: this.id },
|
||||
});
|
||||
this.invalidatedAt = invalidationDate;
|
||||
|
||||
events.emit("session:invalidated", this);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the session
|
||||
*
|
||||
* Terminating the session will immediately delete the session making it impossible for it to be referenced again.
|
||||
*
|
||||
* @returns {Promise<void>} - nothing
|
||||
*/
|
||||
public async terminate() {
|
||||
await prisma.session.delete({ where: { id: this.id } });
|
||||
events.emit("session:terminated", this);
|
||||
this.terminated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Tokens
|
||||
*
|
||||
* **NOTE**: This method can only be ran once per session.
|
||||
*
|
||||
* Running this function will allow you to generate an accessToken and a refreshToken. Each token
|
||||
* will be generated with their own respective private keys.
|
||||
*
|
||||
*
|
||||
* @returns {Promise<SessionTokensObject>} An object containing the `accessToken` and `refreshToken`
|
||||
*/
|
||||
public async generateTokens(): Promise<SessionTokensObject> {
|
||||
if (this._refreshTokenGenrated)
|
||||
throw new Error("Tokens have alredy been generated for this session.");
|
||||
|
||||
const accessToken = this._generateAccessToken();
|
||||
const refreshToken = this._generateRefreshToken();
|
||||
|
||||
const newRefreshDate = new Date();
|
||||
await prisma.session.update({
|
||||
data: { refreshTokenGenerated: true, refreshedAt: newRefreshDate },
|
||||
where: { id: this.id },
|
||||
});
|
||||
this._refreshTokenGenrated = true;
|
||||
this.refreshedAt = newRefreshDate;
|
||||
|
||||
let tokens = { accessToken, refreshToken };
|
||||
|
||||
events.emit("session:tokens_generated", { session: this, tokens });
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the session
|
||||
*
|
||||
* Refreshing the session will generate a new accessToken for the user to authenticate their requests with.
|
||||
*
|
||||
* **NOTE**: Best practice when implementing token refreshing into the UI is that if for any reason this method
|
||||
* throws an error, imediately purge the existing tokens and have the user login again. This way you don't hold
|
||||
* them up any longer than necessary.
|
||||
*
|
||||
* @param refreshToken - The refresh token provided at session generation
|
||||
* @returns {Promise<string>} The new access token.
|
||||
*/
|
||||
public refresh(refreshToken: string): Promise<string> {
|
||||
return new Promise(async (res, rej) => {
|
||||
if (this.expires.getTime() <= Date.now()) {
|
||||
await this.terminate();
|
||||
throw new SessionTokenError("Session has Expired.");
|
||||
}
|
||||
jwt.verify(
|
||||
refreshToken,
|
||||
refreshTokenPrivateKey,
|
||||
{
|
||||
algorithms: ["RS256"],
|
||||
},
|
||||
async (err, decode) => {
|
||||
if (err) {
|
||||
if (
|
||||
err.name == "TokenExpiredError" ||
|
||||
err.message == "invalid signature"
|
||||
)
|
||||
this.terminate();
|
||||
rej(err);
|
||||
}
|
||||
const data: DecodedSession = decode as DecodedSession;
|
||||
|
||||
if (
|
||||
data.sessionKey !== this.sessionKey ||
|
||||
data.userID !== this.userId
|
||||
)
|
||||
rej(
|
||||
new SessionTokenError(
|
||||
"Refresh token does not match this session."
|
||||
)
|
||||
);
|
||||
|
||||
await prisma.session.update({
|
||||
data: { refreshedAt: new Date() },
|
||||
where: { id: this.id },
|
||||
});
|
||||
|
||||
const newToken = this._generateAccessToken();
|
||||
events.emit("session:token_refresh", {
|
||||
session: this,
|
||||
tokens: { accessToken: newToken, refreshToken },
|
||||
});
|
||||
|
||||
return res(newToken);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Session User
|
||||
*
|
||||
* Fetch the user controller of the user that created the session.
|
||||
*
|
||||
* @returns {Promise<UserController>} The user that created this session.
|
||||
*/
|
||||
public async fetchUser(): Promise<UserController> {
|
||||
return (await users.fetchUser({ id: this.userId }))!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UnifiSite } from "../../generated/prisma/client";
|
||||
|
||||
/**
|
||||
* UniFi Site Controller
|
||||
*
|
||||
* Handles formatting and presentation of UniFi site data.
|
||||
*/
|
||||
export class UnifiSiteController {
|
||||
public readonly id: string;
|
||||
public readonly name: string;
|
||||
public readonly siteId: string;
|
||||
public readonly companyId: string | null;
|
||||
|
||||
constructor(site: UnifiSite) {
|
||||
this.id = site.id;
|
||||
this.name = site.name;
|
||||
this.siteId = site.siteId;
|
||||
this.companyId = site.companyId;
|
||||
}
|
||||
|
||||
public toJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
siteId: this.siteId,
|
||||
companyId: this.companyId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { Role } from "../../generated/prisma/client";
|
||||
import { User } from "../../generated/prisma/browser";
|
||||
import { SessionTokensObject } from "./SessionController";
|
||||
import { sessions } from "../managers/sessions";
|
||||
import BodyError from "../Errors/BodyError";
|
||||
import { prisma } from "../constants";
|
||||
import { events } from "../modules/globalEvents";
|
||||
import { RoleController } from "./RoleController";
|
||||
import { roles } from "../managers/roles";
|
||||
import { signPermissions } from "../modules/permission-utils/signPermissions";
|
||||
import { DecodedPermissionsBlock } from "../types/PermissionTypes";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { permissionsPrivateKey } from "../constants";
|
||||
|
||||
export default class UserController {
|
||||
public id: string;
|
||||
public name: string | null;
|
||||
public login: string;
|
||||
public email: string;
|
||||
public image: string | null;
|
||||
public cwIdentifier: string | null;
|
||||
|
||||
private _roles: Collection<string, Role>;
|
||||
private _permissions: string | null;
|
||||
|
||||
/** Cached result of fetchRoles() — populated on first hasPermission call. */
|
||||
private _resolvedRoleControllers: Collection<string, RoleController> | null =
|
||||
null;
|
||||
|
||||
/** Per-permission result cache — avoids repeated JWT verification + DB lookups. */
|
||||
private _permissionCache: Map<string, boolean> = new Map();
|
||||
|
||||
public createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
constructor(userdata: User & { roles: Role[] }) {
|
||||
this.id = userdata.id;
|
||||
this.name = userdata.name;
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
this._permissions = userdata.permissions ?? null;
|
||||
|
||||
this._roles = (() => {
|
||||
let collection = new Collection<string, Role>();
|
||||
userdata.roles.map((v: any) => collection.set(v.id, v));
|
||||
|
||||
return collection;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the internal values
|
||||
*
|
||||
* This is an internal method used to update all the internal values when we query the database. This way
|
||||
* everything stays upto date even when we pass around the user controller.
|
||||
*
|
||||
* @param userdata - User object from Prisma
|
||||
*/
|
||||
private _updateInternalValues(userdata: User) {
|
||||
this.id = userdata.id;
|
||||
this.name = userdata.name;
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Session
|
||||
*
|
||||
* This will create a session in the database that is linked to the user and will then create a pair of access and refresh
|
||||
* tokens to provide to the user such that they can authorized their api requests.
|
||||
*
|
||||
* @returns {Promise<SessionTokensObject>} - Object with an access token and a refresh token.
|
||||
*/
|
||||
public async createSession(): Promise<SessionTokensObject> {
|
||||
return sessions.create({ user: this });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user
|
||||
*
|
||||
* Take in a partial of the user data and validate it then updated it if it passes validation and return
|
||||
* the updated `UserController` object.
|
||||
*
|
||||
* @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)
|
||||
throw new BodyError("Body cannot be empty.");
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: this.id },
|
||||
data,
|
||||
});
|
||||
|
||||
this._updateInternalValues(updatedUser);
|
||||
events.emit("user:updated", { user: this, updatedValues: data });
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Roles
|
||||
*
|
||||
* Replace the user's roles with the provided array of role identifiers (id or moniker).
|
||||
* Validates that each role exists before assigning.
|
||||
*
|
||||
* @param roleIdentifiers - Array of role ids or monikers to assign
|
||||
* @returns {Promise<UserController>} - The updated user controller
|
||||
*/
|
||||
public async setRoles(roleIdentifiers: string[]): Promise<UserController> {
|
||||
const resolvedRoles = await Promise.all(
|
||||
roleIdentifiers.map((identifier) => roles.fetch(identifier)),
|
||||
);
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: this.id },
|
||||
data: {
|
||||
roles: {
|
||||
set: resolvedRoles.map((r) => ({ id: r.id })),
|
||||
},
|
||||
},
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
this._updateInternalValues(updatedUser);
|
||||
this._roles = new Collection<string, Role>();
|
||||
updatedUser.roles.map((v: any) => this._roles.set(v.id, v));
|
||||
this.clearPermissionCache();
|
||||
|
||||
for (const role of resolvedRoles) {
|
||||
events.emit("user:role:assigned", { user: this, role });
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Permissions
|
||||
*
|
||||
* Replace the user's direct permissions with the provided array of permission strings.
|
||||
* Signs the permissions with the user issuer before storing.
|
||||
*
|
||||
* @param permissions - Array of permission node strings to assign
|
||||
* @returns {Promise<UserController>} - The updated user controller
|
||||
*/
|
||||
public async setPermissions(permissions: string[]): Promise<UserController> {
|
||||
const signed = signPermissions({
|
||||
issuer: "user",
|
||||
subject: this.id,
|
||||
permissions,
|
||||
});
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: this.id },
|
||||
data: { permissions: signed },
|
||||
});
|
||||
|
||||
this._updateInternalValues(updatedUser);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Permissions
|
||||
*
|
||||
* Verifies and decodes the user's direct permissions JWT and returns the array of
|
||||
* permission node strings. Returns an empty array if the user has no direct permissions.
|
||||
*
|
||||
* @returns {string[]} The user's direct permission nodes
|
||||
*/
|
||||
public readPermissions(): string[] {
|
||||
if (!this._permissions) return [];
|
||||
|
||||
const decoded = jwt.verify(this._permissions, permissionsPrivateKey, {
|
||||
algorithms: ["RS256"],
|
||||
issuer: "user",
|
||||
subject: this.id,
|
||||
}) as DecodedPermissionsBlock;
|
||||
|
||||
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
|
||||
*
|
||||
* This method will fetch all of the roles that a user belongs to and will return each of their controllers in a collection
|
||||
* of role id's and RoleControllers.
|
||||
*
|
||||
* @returns {Promise<Collection<string, RoleController>>} A collection of all the roles a user has
|
||||
*/
|
||||
public async fetchRoles(): Promise<Collection<string, RoleController>> {
|
||||
const collection = new Collection<string, RoleController>();
|
||||
|
||||
await Promise.all(
|
||||
this._roles.map(async (v) =>
|
||||
collection.set(v.id, await roles.fetch(v.id)),
|
||||
),
|
||||
);
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Permission
|
||||
*
|
||||
* Check if this user has this specific permission. This method will not only check explicit permissions defined in
|
||||
* the database under users and roles, but will also generate implicit permissions for resources that the user has
|
||||
* access to but doesn't specifically have defined under any given permissions object.
|
||||
*
|
||||
* @param permission - The permission to check for
|
||||
* @returns {boolean} Does this user have the specified permission
|
||||
*/
|
||||
public async hasPermission(permission: string) {
|
||||
// Fast path: return cached result if we already resolved this permission
|
||||
const cached = this._permissionCache.get(permission);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Resolve role controllers once and cache them for the lifetime of this
|
||||
// controller instance (i.e. the current request).
|
||||
if (!this._resolvedRoleControllers) {
|
||||
this._resolvedRoleControllers = await this.fetchRoles();
|
||||
}
|
||||
|
||||
const result = this._resolvedRoleControllers
|
||||
.map((v) => v.checkPermission(permission))
|
||||
.includes(true);
|
||||
|
||||
this._permissionCache.set(permission, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Permission Cache
|
||||
*
|
||||
* Invalidates the in-memory permission cache so the next
|
||||
* `hasPermission` call re-fetches roles from the database.
|
||||
* Call this after role or permission mutations on the user.
|
||||
*/
|
||||
public clearPermissionCache() {
|
||||
this._resolvedRoleControllers = null;
|
||||
this._permissionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Create an object that can be safely returned to the user of an api request such that when you
|
||||
* need to return data to the end user, you don't accidently return data that could be harmful
|
||||
* if leaked.
|
||||
*
|
||||
* Options:
|
||||
* - Safe return is to return only data that is considered "safe", and not detrimental to pass around
|
||||
*
|
||||
* @param opts - Options to change the output
|
||||
* @returns - An object that is JSON friendly
|
||||
*/
|
||||
public toJson(opts?: { safeReturn: boolean }) {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
roles: opts?.safeReturn
|
||||
? undefined
|
||||
: this._roles.size > 0
|
||||
? this._roles.map((v) => v.moniker)
|
||||
: undefined,
|
||||
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,
|
||||
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
|
||||
image: this.image,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user