Files
optima/src/controllers/UserController.ts
T
HoloPanio d7b374f8ab feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests
New features:
- ActivityController and manager for CW sales activities (CRUD)
- ForecastProductController for opportunity forecast/product lines
- CW member cache with dual-layer (in-memory + Redis) resolution
- Catalog category/subcategory/ecosystem taxonomy module
- Quote statuses type definitions with CW mapping
- User-defined fields (UDF) module with cache and event refresh
- Company sites CW module with serialization
- Procurement manager filters (category, ecosystem, manufacturer, price, stock)
- Opportunity notes CRUD and product line management via CW API
- Opportunity type definitions endpoint

Updates:
- OpportunityController: CW refresh, company hydration, activities, custom fields
- UserController: cwIdentifier field for CW member linking
- CatalogItemController: category/subcategory fields from CW
- PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions
- API routes: procurement categories/filters, sales notes/products, opportunity types
- Global events: UDF and member refresh intervals on startup

Tests (414 passing):
- ActivityController, ForecastProductController, OpportunityController unit tests
- UserController cwIdentifier tests
- catalogCategories, companySites, memberCache, procurement module tests
- activityTypes, opportunityTypes, quoteStatuses type tests
- permissionNodes subCategories and getAllPermissionNodes tests
- Updated test setup with redis mock, API method mocks, and builder helpers
2026-03-01 13:19:00 -06:00

327 lines
10 KiB
TypeScript

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;
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));
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) {
let resources = await prisma.user.findFirst({
where: { id: this.id },
select: {
sessions: {
select: { id: true },
},
},
});
const resourceKeys: string[] = Object.keys(resources ?? {}) as string[];
const implicitPermissions = resources
? resourceKeys
// @ts-ignore
.filter((v) => resources[v].length > 0)
.map(
(v) =>
//@ts-ignore
`resource.${v}.[${(resources![v] as { id: string }[])
.map((o) => o.id)
.join(",")}].user.${this.id}.implicit`,
)
: [];
let checks = [
(await this.fetchRoles()).map((v) => v.checkPermission(permission)),
].flatMap((v) => v);
return checks.includes(true);
}
/**
* 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,
};
}
}