fix: remove nested .git folders, re-add as normal directories

This commit is contained in:
2026-03-22 17:50:47 -05:00
parent f55c7e47c9
commit 6b7eec67b8
1870 changed files with 4170168 additions and 3 deletions
+252
View File
@@ -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,
};
}
}
+204
View File
@@ -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
);
})(),
})),
},
};
}
}
+418
View File
@@ -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,
};
}
}
+86
View File
@@ -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
+351
View File
@@ -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;
}
}
+228
View File
@@ -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,
};
}
}
+333
View File
@@ -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,
};
}
}