diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 086c8443..112c86d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,10 @@ The application is built using the following primary technologies: - [MikroORM](https://mikro-orm.io/) - [Docker](https://www.docker.com/) +## Data model + + + ## Application Architecture The application is a [monolith](https://martinfowler.com/bliki/MonolithFirst.html) following a layered architecture. The application is split into the following layers: diff --git a/application/AppUserInteractor.ts b/application/AppUserInteractor.ts new file mode 100644 index 00000000..b8717333 --- /dev/null +++ b/application/AppUserInteractor.ts @@ -0,0 +1,69 @@ +import type { AppUserRepository } from "~/server/data/repositories/AppUserRepository"; +import { Interactor } from "./Interactor"; +import type { AppUser } from "~/domain/application"; + +/** + * Interactor for the AppUser + */ +export class AppUserInteractor extends Interactor { + /** + * Create a new AppUserInteractor + * @param props.userId - The id of the accessing user whose permissions are being checked + * @param props.repository - The repository for the AppUser + */ + constructor(props: { + // TODO: This should be Repository + repository: AppUserRepository, + userId: AppUser['id'] + }) { + super({ repository: props.repository, userId: props.userId }) + } + + protected async _isSystemAdmin(): Promise { + const currentUser = await this.getAppUserById(this._userId) + return currentUser.isSystemAdmin + } + + // TODO: This should not be necessary + get repository(): AppUserRepository { + return this._repository as AppUserRepository + } + + /** + * Get the app user by id + * @param id - The id of the app user + * @returns The app user + * @throws {Error} If the user does not exist + */ + async getAppUserById(id: AppUser['id']): Promise { + return this.repository.getUserById(id) + } + + /** + * Get the organization ids associated with the app user + * @param id - The id of the app user + * @returns The organization ids associated with the app user + * @throws {Error} If the user does not exist + * @throws {Error} If the current user is not a system admin or the user being queried is not the current user + */ + async getUserOrganizationIds(id: AppUser['id']): Promise { + if (!(await this.repository.hasUser(id))) + throw new Error(`User with id ${id} does not exist`) + if (id !== this._userId && !this._isSystemAdmin()) + throw new Error(`User with id ${this._userId} does not have permission to get organization ids for user with id ${id}`) + + return this.repository.getUserOrganizationIds(id) + } + + /** + * Check if the requested user exists + * @param id - The id of the user + * @returns Whether the user exists + * @throws {Error} If the current user is not a system admin or the user being queried is not the current user + */ + async hasUser(id: AppUser['id']): Promise { + if (id !== this._userId && !this._isSystemAdmin()) + throw new Error(`User with id ${this._userId} does not have permission to check if user with id ${id} exists`) + return this.repository.hasUser(id) + } +} \ No newline at end of file diff --git a/application/Interactor.ts b/application/Interactor.ts new file mode 100644 index 00000000..de8cae0b --- /dev/null +++ b/application/Interactor.ts @@ -0,0 +1,29 @@ +import type { AppUser } from "~/domain/application" +import type { Requirement } from "~/domain/requirements" +import type { Repository } from "~/server/data/repositories/Repository" + +/** + * An Interactor is a class that contains the business logic for a collection of related use cases + * It has the Single Responsibility (SRP) of interacting with a particular entity. + * @see https://softwareengineering.stackexchange.com/a/364727/420292 + */ +// TODO: E should be a type that extends Entity | ValueObject +// Though more accurately, should be a type that implements Equatable +export abstract class Interactor { + protected readonly _repository + protected readonly _userId + + /** + * Create a new Interactor + * + * @param props.repository - The repository to use + * @param props.userId - The id of the user to utilize + */ + constructor(props: { + repository: Repository, + userId: AppUser['id'] + }) { + this._repository = props.repository + this._userId = props.userId + } +} \ No newline at end of file diff --git a/application/Mapper.ts b/application/Mapper.ts deleted file mode 100644 index f3e4f34b..00000000 --- a/application/Mapper.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default abstract class Mapper { - abstract mapTo(source: Source): Target; - abstract mapFrom(target: Target): Source; -} \ No newline at end of file diff --git a/application/OrganizationInteractor.ts b/application/OrganizationInteractor.ts index 15e362e8..793689c7 100644 --- a/application/OrganizationInteractor.ts +++ b/application/OrganizationInteractor.ts @@ -1,72 +1,76 @@ -import { Assumption, Constraint, Effect, EnvironmentComponent, FunctionalBehavior, GlossaryTerm, Invariant, Justification, Limit, MoscowPriority, NonFunctionalBehavior, Obstacle, Organization, Outcome, ParsedRequirement, Person, ReqType, Requirement, Solution, Stakeholder, StakeholderCategory, StakeholderSegmentation, SystemComponent, UseCase, UserStory } from "~/domain/requirements"; -import { Collection, QueryOrder, type ChangeSetType, type EntityManager } from "@mikro-orm/core"; -import { AppUserOrganizationRole, AppRole, AppUser, AuditLog } from "~/domain/application"; -import { validate } from 'uuid' +import * as req from "~/domain/requirements"; +import { AppUserOrganizationRole, AppRole, AppUser } from "~/domain/application"; import type NaturalLanguageToRequirementService from "~/server/data/services/NaturalLanguageToRequirementService"; -import { groupBy, slugify } from "#shared/utils"; +import { groupBy } from "#shared/utils"; import { Belongs, Follows } from "~/domain/relations"; - -type OrganizationInteractorConstructor = { - entityManager: EntityManager, - userId: AppUser['id'], - organizationId?: Organization['id'], - organizationSlug?: Organization['slug'] -} +import type { OrganizationRepository } from "~/server/data/repositories/OrganizationRepository"; +import { validate as validateUuid } from 'uuid' +import type { AuditMetadata } from "~/domain/AuditMetadata"; +import { Interactor } from "./Interactor"; /** * The OrganizationInteractor class contains the business logic for interacting with an organization. - * - * An Interactor is a class that contains the business logic of an application. - * It has the Single Responsibility (SRP) of interacting with a particular entity. - * @see https://softwareengineering.stackexchange.com/a/364727/420292 */ -export class OrganizationInteractor { - private readonly _entityManager: EntityManager - private readonly _user: Promise - /** This is not readonly because it can be reassigned in {@link #deleteOrganization} */ - private _organization?: Promise - +export class OrganizationInteractor extends Interactor { /** * Create a new OrganizationInteractor * - * @param props.entityManager - The entity manager to use + * @param props.repository - The repository to use * @param props.userId - The id of the user to utilize - * @param [props.organizationId] - The id of the organization to utilize - * @param [props.organizationSlug] - The slug of the organization to utilize */ - constructor(props: OrganizationInteractorConstructor) { - this._entityManager = props.entityManager - - const em = this.getEntityManager(), - { userId, organizationId, organizationSlug } = props - - this._user = em.findOneOrFail(AppUser, userId) - this._organization = organizationId ? em.findOneOrFail(Organization, organizationId) : - organizationSlug ? em.findOneOrFail(Organization, { slug: organizationSlug }) : - undefined + constructor(props: { + // TODO: This should be Repository + repository: OrganizationRepository, + userId: AppUser['id'] + }) { super(props) } + + // FIXME: this shouldn't be necessary + get repository(): OrganizationRepository { + return this._repository as OrganizationRepository } /** - * Gets the next requirement id for the given solution and requirement type + * Add an appuser to the organization with a role * - * @param prefix - The prefix for the requirement id. Ex: 'P.1.' + * @param props.appUserId The id of the app user to invite + * @param props.role The role to assign to the app user + * @throws {Error} If the user is not an admin of the organization + * @throws {Error} If the organization does not exist + * @throws {Error} If the target app user does not exist + * @throws {Error} If the target app user is already associated with the organization */ - private async _getNextReqId(solution: Solution, prefix: R['reqIdPrefix']): Promise['reqId']> { - const entityCount = await solution.contains.loadCount({ - where: { reqId: { $like: `${prefix}%` } } - }); + async addAppUserOrganizationRole(props: Pick): Promise { + if (!await this.isOrganizationAdmin()) + throw new Error('Forbidden: You do not have permission to perform this action') - return `${prefix}${entityCount + 1}` + this.repository.addAppUserOrganizationRole({ + createdById: this._userId, + effectiveDate: new Date(), + ...props + }) } /** - * Create a new entity manager fork + * Creates a new organization in the database and sets the creator as an admin * - * @returns The entity manager + * @param props.name The name of the organization + * @param props.description The description of the organization + * @returns The new organization id */ - // TODO: this will likely be moved into a Repository class in the future - getEntityManager(): EntityManager { - return this._entityManager.fork(); + async addOrganization(props: Pick): Promise { + const repo = this.repository, + effectiveDate = new Date(), + newOrgId = await repo.addOrganization({ ...props, createdById: this._userId, effectiveDate }) + + await repo.addAppUserOrganizationRole({ + effectiveDate, + appUserId: this._userId, + organizationId: newOrgId, + role: AppRole.ORGANIZATION_ADMIN, + createdById: this._userId + }) + + return newOrgId } /** @@ -82,49 +86,99 @@ export class OrganizationInteractor { * @throws {Error} If the user is not a contributor of the solution or better * @throws {Error} If a referenced requirement does not belong to the solution */ - async addRequirement({ + async addRequirement({ solutionId, ReqClass, reqProps }: { - solutionId: Solution['id'], + solutionId: req.Solution['id'], ReqClass: RCons, - reqProps: Omit[0], 'reqId' | 'lastModified' | 'modifiedBy' | 'createdBy'> - }): Promise> { + reqProps: Omit, 'reqId' | 'id' | keyof AuditMetadata> + }): Promise { if (!this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') - const solution = await this.getSolutionById(solutionId) + await this._assertReferenceRequirementsBelongToSolution({ solutionId, reqProps }) - if (!solution) - throw new Error('Not Found: The solution does not exist.') + return await this.repository.addRequirement({ + solutionId, + ReqClass, + reqProps, + createdById: this._userId, + effectiveDate: new Date() + }) + } - const em = this.getEntityManager(), - appUser = await this._user, - // If the entity is silent, do not assign a reqId - reqId = reqProps.isSilence ? undefined : - await this._getNextReqId(solution, ReqClass.reqIdPrefix); + /** + * Add a solution to an organization + * + * @param props The properties of the solution + * @returns The new solution + * @throws {Error} If the user is not an admin of the organization + * @throws {Error} If the organization does not exist + */ + async addSolution({ name, description }: Pick): Promise { + const repo = this.repository, + effectiveDate = new Date() - // if a property is a uuid, assert that it belongs to the solution - for (const [key, value] of Object.entries(reqProps) as [keyof typeof reqProps, string][]) { - if (validate(value) && !['id', 'createdBy', 'modifiedBy'].includes(key as string)) { - const reqExists = await solution.contains.loadCount({ where: { id: value } }) - if (reqExists === 0) - throw new Error(`Not Found: The referenced requirement with id ${value} does not exist in the solution.`) - } - } + if (!await this.isOrganizationAdmin()) + throw new Error('Forbidden: You do not have permission to perform this action') - const newRequirement = em.create(ReqClass, { - ...reqProps, - reqId, - lastModified: new Date(), - createdBy: appUser, - modifiedBy: appUser - }) as InstanceType + const newSolutionId = await repo.addSolution({ name, description, effectiveDate, createdById: this._userId }) - em.create(Belongs, { left: newRequirement, right: solution }) + // create initial requirements for the solution + await this.addRequirement({ + solutionId: newSolutionId, + ReqClass: req.Outcome, + reqProps: { name: 'G.1', description: 'Context and Objective', isSilence: false } + }) - await em.flush() + await this.addRequirement({ + solutionId: newSolutionId, + ReqClass: req.Obstacle, + reqProps: { name: 'G.2', description: 'Situation', isSilence: false } + }) - return newRequirement + return newSolutionId + } + + /** + * Delete an app user from the current organization + * + * @param id The id of the app user to delete + * @throws {Error} If the user is not an admin of the organization unless the user is deleting themselves + * @throws {Error} If the user is deleting the last admin of the organization + * @throws {Error} If the target app user does not exist + */ + async deleteAppUser(id: AppUser['id']): Promise { + if (!this.isOrganizationAdmin() && id !== this._userId) + throw new Error('Forbidden: You do not have permission to perform this action') + + const targetUser = await this.getAppUserById(id), + orgAdminCount = (await this.repository.findAppUserOrganizationRoles({ + role: AppRole.ORGANIZATION_ADMIN + })).length + + if (targetUser.role === AppRole.ORGANIZATION_ADMIN && orgAdminCount === 1) + throw new Error('Forbidden: You cannot delete the last organization admin.') + + return this.repository.deleteAppUserOrganizationRole({ + appUserId: id, + deletedById: this._userId, + deletedDate: new Date(), + organizationId: (await this.getOrganization()).id + }) + } + + /** + * Delete the organization + * + * @throws {Error} If the user is not an admin of the organization or better + * @throws {Error} If the organization does not exist + */ + async deleteOrganization(): Promise { + if (!await this.isOrganizationAdmin()) + throw new Error('Forbidden: You do not have permission to perform this action') + + return this.repository.deleteOrganization({ deletedById: this._userId, deletedDate: new Date() }) } /** @@ -138,65 +192,43 @@ export class OrganizationInteractor { * @throws {Error} If the requirement does not exist * @throws {Error} If the requirement does not belong to the solution */ - async deleteRequirement(props: { + async deleteRequirement(props: { id: InstanceType['id'], - solutionId: Solution['id'], + solutionId: req.Solution['id'], ReqClass: RCons }): Promise { if (!this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - solution = await this.getSolutionById(props.solutionId), - requirement = (await solution.contains.loadItems({ - where: { id: props.id, req_type: props.ReqClass.req_type } - }))[0] - - if (!requirement) - throw new Error('Not Found: The requirement does not exist.') - - // remove all relationships to the requirement - const conn = this.getEntityManager().getConnection() - await conn.execute(` - DELETE - FROM requirement_relation - WHERE left_id = ? OR right_id = ?; - `, [requirement.id, requirement.id]) + await this.repository.deleteSolutionRequirementById({ + deletedById: this._userId, + deletedDate: new Date(), + ReqClass: props.ReqClass, + id: props.id, + solutionId: props.solutionId + }) // TODO: decrement the reqId of all requirements that have a reqId greater than the deleted requirement - - await em.removeAndFlush(requirement) + // see https://github.com/final-hill/cathedral/issues/475 } /** - * Get a requirement by id from the specified solution + * Delete a solution by slug from an organization * - * @param props.solutionId The id of the solution that the requirement belongs to - * @param props.ReqClass The Constructor of the requirement to get - * @param props.id The id of the requirement to get - * @returns The requirement + * @param slug The id of the solution to delete + * @throws {Error} If the user is not an admin of the organization * @throws {Error} If the solution does not exist - * @throws {Error} If the user is not a reader of the organization or better - * @throws {Error} If the requirement does not exist - * @throws {Error} If the requirement does not belong to the solution + * @throws {Error} If the solution does not belong to the organization */ - async getRequirementById(props: { - solutionId: Solution['id'], - ReqClass: RCons, - id: InstanceType['id'] - }): Promise> { - if (!this.isOrganizationReader()) + async deleteSolutionBySlug(slug: req.Solution['slug']): Promise { + if (!await this.isOrganizationAdmin()) throw new Error('Forbidden: You do not have permission to perform this action') - const solution = await this.getSolutionById(props.solutionId), - requirement = (await solution.contains.loadItems({ - where: { id: props.id, req_type: props.ReqClass.req_type } - }))[0] as InstanceType - - if (!requirement) - throw new Error('Not Found: The requirement does not exist.') - - return requirement + return this.repository.deleteSolutionBySlug({ + deletedById: this._userId, + deletedDate: new Date(), + slug + }) } /** @@ -208,105 +240,30 @@ export class OrganizationInteractor { * @returns The requirements that match the query parameters * @throws {Error} If the user is not a reader of the organization or better */ - async findRequirements(props: { - solutionId: Solution['id'], + async findSolutionRequirements(props: { + solutionId: req.Solution['id'], ReqClass: RCons, query: Partial> }): Promise[]> { if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const solution = await this.getSolutionById(props.solutionId), - requirements = (await solution.contains.loadItems>({ - where: { - req_type: props.ReqClass.req_type, - ...props.query - } - })) as InstanceType[] - - return requirements - } - - // TODO: allow the re-ordering of requirements in a solution (reqIds) - - /** - * Update a requirement by id for a solution with the given properties - * - * @param props.id The id of the requirement to update - * @param props.solutionId The id of the solution that the requirement belongs to - * @param props.ReqClass The Constructor of the requirement to update - * @param props.reqProps The properties to update - * @throws {Error} If the user is not a contributor of the organization or better - * @throws {Error} If the requirement does not exist - * @throws {Error} If the solution does not exist - * @throws {Error} If the requirement is not owned by the solution - * @throws {Error} If a referenced requirement does not belong to the solution - */ - async updateRequirement(props: { - id: InstanceType['id'], - solutionId: Solution['id'], - ReqClass: RCons, - reqProps: Partial, 'reqId' | 'lastModified' | 'modifiedBy' | 'createdBy'>> - }): Promise { - if (!this.isOrganizationContributor()) - throw new Error('Forbidden: You do not have permission to perform this action') - - const em = this.getEntityManager(), - solution = await this.getSolutionById(props.solutionId), - requirement = (await solution.contains.loadItems({ - where: { id: props.id, req_type: props.ReqClass.req_type } - }))[0] as InstanceType - - if (!requirement) - throw new Error('Not Found: The requirement does not exist.') - - // if a property is a uuid, assert that it belongs to the solution - requirement.assign({ - ...props.reqProps, - lastModified: new Date(), - modifiedBy: await this._user - } as any) // FIXME: TypeScript (v5.6.3) does not infer the proper type here - - // If the entity is no longer silent and has no reqId, assume - // that it is a new requirement from the workbox and assign a new reqId - if (props.reqProps.isSilence !== undefined && props.reqProps.isSilence == false && !requirement.reqId) - requirement.reqId = await this._getNextReqId(solution, props.ReqClass.reqIdPrefix) - - await em.persistAndFlush(requirement) + return this.repository.findSolutionRequirementsByType(props) } /** - * Invite an appuser to an organization with a role + * Find solutions that match the query parameters for an organization * - * @param props.email The email of the app user to invite - * @param props.role The role to assign to the app user - * @throws {Error} If the user is not an admin of the organization + * @param query The query parameters to filter solutions by + * @returns The solutions that match the query parameters + * @throws {Error} If the user is not a reader of the organization or better * @throws {Error} If the organization does not exist - * @throws {Error} If the target app user does not exist - * @throws {Error} If the target app user is already associated with the organization */ - async addAppUserToOrganization({ email, role }: { email: string, role: AppRole }): Promise { - if (!await this.isOrganizationAdmin()) + async findSolutions(query: Partial = {}): Promise { + if (!await this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - appUser = await em.findOne(AppUser, { email }), - organization = await this.getOrganization() - - if (!appUser) - throw new Error('Not Found: The app user with the given email does not exist.') - - const existingOrgAppUserRole = await em.findOne(AppUserOrganizationRole, { - appUser, - organization - }) - - if (existingOrgAppUserRole) - throw new Error('Conflict: The app user is already associated with the organization.') - - em.create(AppUserOrganizationRole, { appUser, organization, role }) - - await em.flush() + return this.repository.findSolutions(query) } /** @@ -319,139 +276,102 @@ export class OrganizationInteractor { * @throws {Error} If the user is trying to get an app user that is not in the same organization */ async getAppUserById(id: AppUser['id']): Promise { - const organization = await this.getOrganization(), - em = this.getEntityManager(), - { appUser, role } = (await em.findOneOrFail(AppUserOrganizationRole, { - appUser: id, - organization - }, { populate: ['appUser'] })) - - appUser.role = role - - if (!appUser) - throw new Error('Not Found: AppUser not found for the given ID.') - if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - - return appUser + return this.repository.getOrganizationAppUserById(id) } /** - * Delete an app user from an organization + * Returns the organization that the user is associated with * - * @throws {Error} If the user is not an admin of the organization unless the user is deleting themselves - * @throws {Error} If the user is deleting the last admin of the organization + * @returns The organization + * @throws {Error} If the organization does not exist */ - async deleteAppUser(id: AppUser['id']): Promise { - const em = this.getEntityManager(), - organization = await this.getOrganization(), - appUser = await this._user, - [targetAppUserRole, orgAdminCount] = await Promise.all([ - em.findOne(AppUserOrganizationRole, { - appUser: id, - organization - }, { populate: ['appUser'] }), - em.count(AppUserOrganizationRole, { - organization, - role: AppRole.ORGANIZATION_ADMIN - }) - ]) - - if (!targetAppUserRole) - throw new Error('Not Found: AppUser not found for the given ID and organization.') - - if (targetAppUserRole.role === AppRole.ORGANIZATION_ADMIN && orgAdminCount === 1) - throw new Error('Forbidden: You cannot delete the last organization admin.') - - if (!this.isOrganizationAdmin() && targetAppUserRole.appUser.id !== appUser.id) + async getOrganization(): Promise { + if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - - return await em.removeAndFlush(targetAppUserRole) + return this.repository.getOrganization() } /** - * Update an app user by id in a given organization to have a new role + * Get all app users for the organization with their associated roles * - * @param id The id of the app user to update - * @param role The new role to assign to the app user - * @throws {Error} If the user is not an admin of the organization - * @throws {Error} If the app user does not exist - * @throws {Error} If the app user is not in the same organization - * @throws {Error} If the app user is the last admin of the organization and the new role is not an admin - * @throws {Error} If the app user is trying to update themselves to a role that is not an admin + * @returns The app users with their associated roles + * @throws {Error} If the user is not a reader of the organization or better */ - async updateAppUserRole(id: AppUser['id'], role: AppRole): Promise { - const em = this.getEntityManager(), - organization = await this.getOrganization(), - appUser = await this._user, - [targetAppUserRole, orgAdminCount] = await Promise.all([ - em.findOne(AppUserOrganizationRole, { - appUser: id, - organization - }, { populate: ['appUser'] }), - em.count(AppUserOrganizationRole, { - organization, - role: AppRole.ORGANIZATION_ADMIN - }) - ]) - - if (!targetAppUserRole) - throw new Error('Not Found: AppUser not found for the given ID and organization.') + async getOrganizationAppUsers(): Promise { + if (!this.isOrganizationReader()) + throw new Error('Forbidden: You do not have permission to perform this action') - if (targetAppUserRole.role === AppRole.ORGANIZATION_ADMIN && orgAdminCount === 1 - && role !== AppRole.ORGANIZATION_ADMIN) - throw new Error('Forbidden: You cannot remove the last organization admin.') + return this.repository.getOrganizationAppUsers() + } - if (!this.isOrganizationAdmin() && targetAppUserRole.appUser.id !== appUser.id) + /** + * Get a solution by id + * + * @param solutionId The id of the solution to get + * @returns The solution + * @throws {Error} If the user is not a reader of the organization or better + * @throws {Error} If the solution does not exist + * @throws {Error} If the solution does not belong to the organization + */ + async getSolutionById(solutionId: req.Solution['id']): Promise { + if (!await this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - if (targetAppUserRole.appUser.id === appUser.id && role !== AppRole.ORGANIZATION_ADMIN) - throw new Error('Forbidden: You cannot remove your own admin role.') + return this.repository.getSolutionById(solutionId) + } - targetAppUserRole.role = role + /** + * Get a solution by slug + * + * @param slug The slug of the solution to get + * @returns The solution + * @throws {Error} If the user is not a reader of the organization or better + * @throws {Error} If the solution does not exist + * @throws {Error} If the solution does not belong to the organization + */ + async getSolutionBySlug(slug: req.Solution['slug']): Promise { + if (!await this.isOrganizationReader()) + throw new Error('Forbidden: You do not have permission to perform this action') - await em.persistAndFlush(targetAppUserRole) + return this.repository.getSolutionBySlug(slug) } /** - * Get all app users for the organization with their associated roles + * Get a requirement by id * - * @returns The app users with their associated roles + * @param props.ReqClass The Constructor of the requirement to get + * @param props.id The id of the requirement to get + * @param props.solutionId The id of the solution that the requirement belongs to + * @returns The requirement * @throws {Error} If the user is not a reader of the organization or better + * @throws {Error} If the requirement does not exist in the organization nor the solution */ - async getOrganizationAppUsers(): Promise { + async getSolutionRequirementById(props: { + ReqClass: RCons, + solutionId: req.Solution['id'], + id: InstanceType['id'] + }): Promise> { if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - organization = await this.getOrganization(), - appUserOrganizationRoles = await em.findAll(AppUserOrganizationRole, { - where: { organization }, - populate: ['appUser'] - }) - - // assign the roles to the appusers - const appUsersWithRoles: AppUser[] = appUserOrganizationRoles.map((aur) => { - const appUser = aur.appUser as AppUser - appUser.role = aur.role - return appUser + return await this.repository.getSolutionRequirementById({ + ReqClass: props.ReqClass, + solutionId: props.solutionId, + id: props.id }) - - return appUsersWithRoles } /** * Check if the current user is an admin of the organization or a system admin */ async isOrganizationAdmin(): Promise { - const appUser = await this._user, - organization = await this.getOrganization() + const appUser = await this.repository.getOrganizationAppUserById(this._userId) if (appUser.isSystemAdmin) return true - const em = this.getEntityManager(), - auor = await em.findOne(AppUserOrganizationRole, { appUser, organization }), + const auor = await this.repository.getAppUserOrganizationRole(appUser.id), isOrgAdmin = auor?.role ? [AppRole.ORGANIZATION_ADMIN].includes(auor.role) : false @@ -463,13 +383,11 @@ export class OrganizationInteractor { * Check if the current user is a contributor of the organization or a system admin */ async isOrganizationContributor(): Promise { - const appUser = await this._user, - organization = await this.getOrganization() + const appUser = await this.repository.getOrganizationAppUserById(this._userId) if (appUser.isSystemAdmin) return true - const em = this.getEntityManager(), - auor = await em.findOne(AppUserOrganizationRole, { appUser, organization }), + const auor = await this.repository.getAppUserOrganizationRole(appUser.id), isOrgContributor = auor?.role ? [AppRole.ORGANIZATION_ADMIN, AppRole.ORGANIZATION_CONTRIBUTOR].includes(auor.role) : false @@ -481,13 +399,11 @@ export class OrganizationInteractor { * Check if the current user is a reader of the organization or a system admin */ async isOrganizationReader(): Promise { - const organization = await this.getOrganization(), - appUser = await this._user + const appUser = await this.repository.getOrganizationAppUserById(this._userId) if (appUser.isSystemAdmin) return true - const em = this.getEntityManager(), - auor = await em.findOne(AppUserOrganizationRole, { appUser, organization }), + const auor = await this.repository.getAppUserOrganizationRole(appUser.id), isOrgReader = auor?.role ? [AppRole.ORGANIZATION_ADMIN, AppRole.ORGANIZATION_CONTRIBUTOR, AppRole.ORGANIZATION_READER].includes(auor.role) : false @@ -496,174 +412,108 @@ export class OrganizationInteractor { } /** - * Creates a new organization in the database and sets the creator as an admin - * - * @param props The properties of the organization - * @returns The new organization - */ - async addOrganization(props: Pick): Promise { - const em = this.getEntityManager(), - appUser = await this._user, - newOrganization = em.create(Organization, { - name: props.name, - description: props.description, - createdBy: appUser, - modifiedBy: appUser, - lastModified: new Date(), - isSilence: false, - reqId: undefined - }) - - em.create(AppUserOrganizationRole, { - appUser: await this._user, - organization: newOrganization, - role: AppRole.ORGANIZATION_ADMIN - }) - - await em.flush() - - return newOrganization - } - - /** - * Add a solution to an organization + * Update a target user by id in a given organization to have a new role * - * @param props The properties of the solution - * @returns The new solution - * @throws {Error} If the user is not an admin of the organization - * @throws {Error} If the organization does not exist + * @param id The id of the target user to update + * @param role The new role to assign to the target user + * @throws {Error} If the current user is not an admin of the organization + * @throws {Error} If the target user does not exist + * @throws {Error} If the target user is not in the same organization + * @throws {Error} If the target user is the last admin of the organization and the new role is not an admin + * @throws {Error} If the target user is trying to update themselves */ - async addSolution({ name, description }: Pick): Promise { - const organization = await this.getOrganization(), - createdBy = await this._user - - if (!await this.isOrganizationAdmin()) + async updateAppUserRole(id: AppUser['id'], role: AppRole): Promise { + if (!this.isOrganizationAdmin()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - newSolution = em.create(Solution, { - name, - description, - lastModified: new Date(), - createdBy, - modifiedBy: createdBy, - isSilence: false - }); + const targetUser = await this.getAppUserById(id) - em.create(Belongs, { left: newSolution, right: organization }) + if (targetUser.id === this._userId) + throw new Error('Forbidden: You cannot update your own role.') - await em.flush() - - await this.addRequirement({ - solutionId: newSolution.id, - ReqClass: Outcome, - reqProps: { name: 'G.1', description: 'Context and Objective', isSilence: false } as ConstructorParameters[0] - }) + if (targetUser.role === AppRole.ORGANIZATION_ADMIN && role !== AppRole.ORGANIZATION_ADMIN) + throw new Error('Forbidden: You cannot remove the last organization admin.') - await this.addRequirement({ - solutionId: newSolution.id, - ReqClass: Obstacle, - reqProps: { name: 'G.2', description: 'Situation', isSilence: false } as ConstructorParameters[0] + await this.repository.updateAppUserRole({ + appUserId: id, + role, + modifiedById: this._userId, + modifiedDate: new Date }) - - return newSolution } /** - * Find solutions that match the query parameters for an organization + * Update the organization with the given properties. * - * @param query The query parameters to filter solutions by - * @returns The solutions that match the query parameters - * @throws {Error} If the user is not a reader of the organization or better + * @param props The properties to update + * @throws {Error} If the user is not a contributor of the organization or better * @throws {Error} If the organization does not exist */ - async findSolutions(query: Partial = {}): Promise { - const organization = await this.getOrganization() - - if (!await this.isOrganizationReader()) + async updateOrganization(props: Pick, 'name' | 'description'>): Promise { + if (!this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') - const solutions = await organization.contains.loadItems({ - where: { - // remove null entries from the query - ...Object.entries(query).reduce((acc, [key, value]) => { - if (value !== null && value !== undefined) acc[key] = value - return acc - }, {} as Record), - req_type: ReqType.SOLUTION - } + await this.repository.updateOrganization({ + modifiedById: this._userId, + modifiedDate: new Date(), + ...props }) - - return solutions } /** - * Get a solution by id - * - * @param solutionId The id of the solution to get - * @returns The solution - * @throws {Error} If the user is not a reader of the organization or better - * @throws {Error} If the solution does not exist - * @throws {Error} If the solution does not belong to the organization + * Assert that all requirement references (uuid properties) belong to the same solution. + * This is to prevent a requirement from another solution being added to the current solution. + * This is a security measure to prevent unauthorized access to requirements */ - async getSolutionById(solutionId: Solution['id']): Promise { - if (!await this.isOrganizationReader()) - throw new Error('Forbidden: You do not have permission to perform this action') - - const organization = await this.getOrganization(), - solution = (await organization.contains.loadItems({ - where: { id: solutionId, req_type: ReqType.SOLUTION } - }))[0] - - if (!solution) - throw new Error('Not Found: The solution does not exist.') - - return solution - } - - /** - * Get a solution by slug - * - * @param slug The slug of the solution to get - * @returns The solution - * @throws {Error} If the user is not a reader of the organization or better - * @throws {Error} If the solution does not exist - * @throws {Error} If the solution does not belong to the organization - */ - async getSolutionBySlug(slug: Solution['slug']): Promise { - if (!await this.isOrganizationReader()) - throw new Error('Forbidden: You do not have permission to perform this action') - - const organization = await this.getOrganization(), - solution = (await organization.contains.loadItems({ - where: { slug, req_type: ReqType.SOLUTION } - }))[0] - - if (!solution) - throw new Error('Not Found: The solution does not exist.') - - return solution + private async _assertReferenceRequirementsBelongToSolution(props: { + solutionId: req.Solution['id'], + reqProps: Partial> + }) { + for (const [_, value] of Object.entries(props.reqProps) as [keyof typeof props.reqProps, string][]) { + if (validateUuid(value)) { + const solHasReq = await this.repository.solutionHasRequirement({ id: value, solutionId: props.solutionId }) + + if (!solHasReq) + throw new Error(`Requirement with id ${value} does not belong to the solution`) + } + } } /** - * Delete a solution by slug from an organization + * Update a requirement by id for a solution with the given properties * - * @param slug The id of the solution to delete - * @throws {Error} If the user is not an admin of the organization + * @param props.id The id of the requirement to update + * @param props.solutionId The id of the solution that the requirement belongs to + * @param props.ReqClass The Constructor of the requirement to update + * @param props.reqProps The properties to update + * @throws {Error} If the user is not a contributor of the organization or better + * @throws {Error} If the requirement does not exist * @throws {Error} If the solution does not exist - * @throws {Error} If the solution does not belong to the organization + * @throws {Error} If the requirement is not owned by the solution + * @throws {Error} If a referenced requirement does not belong to the solution */ - async deleteSolutionBySlug(slug: Solution['slug']): Promise { - if (!await this.isOrganizationAdmin()) + async updateSolutionRequirement(props: { + id: InstanceType['id'], + solutionId: req.Solution['id'], + ReqClass: RCons, + reqProps: Partial, 'id' | 'reqId' | keyof AuditMetadata>> + }): Promise { + if (!this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - solution = await this.getSolutionBySlug(slug) - - if (!solution) - throw new Error('Not Found: The solution does not exist.') + await this._assertReferenceRequirementsBelongToSolution({ + solutionId: props.solutionId, + reqProps: props.reqProps + }) - await em.removeAndFlush(solution) + await this.repository.updateSolutionRequirement({ + modifiedById: this._userId, + modifiedDate: new Date(), + ReqClass: props.ReqClass, + requirementId: props.id, + solutionId: props.solutionId, + reqProps: props.reqProps + }) } /** @@ -675,193 +525,17 @@ export class OrganizationInteractor { * @throws {Error} If the solution does not exist * @throws {Error} If the solution does not belong to the organization */ - async updateSolutionBySlug(slug: Solution['slug'], props: Pick, 'name' | 'description'>): Promise { - if (!await this.isOrganizationContributor()) - throw new Error('Forbidden: You do not have permission to perform this action') - - const em = this.getEntityManager(), - solution = await this.getSolutionBySlug(slug) - - if (!solution) - throw new Error('Not Found: The solution does not exist.') - - solution.assign({ - name: props.name ?? solution.name, - slug: props.name ? slugify(props.name) : solution.slug, - description: props.description ?? solution.description, - modifiedBy: await this._user, - lastModified: new Date() - }) - - await em.persistAndFlush(solution) - } - - /** - * Get all organizations that the current user is associated with filtered by the query parameters - * - * @param query The query parameters to filter organizations by - */ - async getUserOrganizations(query: Partial = {}): Promise { - const em = this.getEntityManager(), - appUser = await this._user - - // If the user is a system admin, return all organizations - // filtered by the query parameters - if (appUser.isSystemAdmin) { - return em.findAll(Organization, { - where: Object.entries(query).reduce((acc, [key, value]) => { - if (value) acc[key] = value - return acc - }, {} as Record) - }) - } - - // If the user is not a system admin, return only organizations - // that the user is associated with filtered by the query parameters - return (await em.findAll(AppUserOrganizationRole, { - where: { - appUser, - organization: Object.entries(query).reduce((acc, [key, value]) => { - if (value) acc[key] = value - return acc - }, {} as Record) - }, - populate: ['organization'] - })).map((auor) => auor.organization) - } - - /** - * Returns the organization that the user is associated with - * - * @returns The organization - * @throws {Error} If the organization does not exist - */ - async getOrganization(): Promise { - if (!this._organization) - throw new Error('Not Found: The organization does not exist.') - return await this._organization - } - - /** - * Delete the organization - * - * @throws {Error} If the user is not an admin of the organization or better - * @throws {Error} If the organization does not exist - */ - async deleteOrganization(): Promise { - const em = this.getEntityManager(), - organization = await this.getOrganization() - - if (!await this.isOrganizationAdmin()) - throw new Error('Forbidden: You do not have permission to perform this action') - - // Remove all roles associated with the organization - const appUserOrganizationRoles = await em.findAll(AppUserOrganizationRole, { - where: { organization } - }) - - for (const auor of appUserOrganizationRoles) - em.remove(auor) - - await em.removeAndFlush(organization) - - this._organization = undefined - } - - /** - * Update the organization with the given properties. - * - * @param props The properties to update - * @throws {Error} If the user is not a contributor of the organization or better - * @throws {Error} If the organization does not exist - */ - async updateOrganization(props: Pick, 'name' | 'description'>): Promise { - const em = this.getEntityManager(), - organization = await this.getOrganization() - + async updateSolutionBySlug(slug: req.Solution['slug'], props: Pick, 'name' | 'description'>): Promise { if (!await this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') - organization.assign({ - name: props.name ?? organization.name, - description: props.description ?? organization.description, - slug: props.name ? slugify(props.name) : organization.slug, - modifiedBy: await this._user, - lastModified: new Date() - }) - - await em.persistAndFlush(organization) - } - - /** - * Get the audit log history for a specific entity - * - * @param entityId The id of the entity to get the audit log history for - * @returns The audit log history - * @throws {Error} If the user is not a reader of the organization or better - */ - async getAuditLogHistory(entityId: Requirement['id']): Promise { - if (!await this.isOrganizationReader()) - throw new Error('Forbidden: You do not have permission to perform this action') - - // FIXME: check the that the AuditLog entity belongs to the organization - // This is probably not necessary since the Audit Log is going away: - // https://github.com/final-hill/cathedral/issues/435 - - const em = this.getEntityManager() - - return await em.findAll(AuditLog, { - where: { entityId }, - orderBy: { createdAt: QueryOrder.DESC } + await this.repository.updateSolutionBySlug(slug, { + modifiedById: this._userId, + modifiedDate: new Date(), + ...props }) } - /** - * Returns from the audit history all the deleted entities - * ordered by the date and time when they were deleted in descending order. - * - * @param entityName The name of the entity to get the audit log delete history for - * @returns The audit log delete history - * @throws {Error} If the user is not a reader of the organization or better - */ - async getAuditLogDeleteHistory(entityName: string): Promise { - type RowType = { - id: string, - type: ChangeSetType, - created_at: string, - entity_id: string, - entity_name: string, - entity: string - } - - const conn = this.getEntityManager().getConnection(), - res: RowType[] = await conn.execute(` - SELECT d.id, d.type, d.created_at, a.entity_id, a.entity_name, a.entity - FROM audit_log AS d - JOIN audit_log AS a - ON a.entity_id = d.entity_id - AND a.created_at = ( - SELECT MAX(a2.created_at) - FROM audit_log AS a2 - WHERE a2.entity_id = d.entity_id - AND a2.type IN ('create', 'update') - AND a2.created_at < d.created_at - ) - WHERE d.type = 'delete' - AND a.entity_name = ?; - `, [entityName]), - auditLogs = res.map((row) => new AuditLog({ - id: row.id, - createdAt: new Date(row.created_at), - type: row.type, - entityId: row.entity_id, - entityName: row.entity_name, - entity: row.entity - })) - - return auditLogs - } - /** * Get all unapproved (isSilence) requirements that follow from the specified ParsedRequirement for a Solution * @param props.solutionId The id of the solution that the requirement belongs to @@ -871,17 +545,18 @@ export class OrganizationInteractor { * @throws {Error} If the ParsedRequirement does not exist * @throws {Error} If the ParsedRequirement does not belong to the solution */ - async getFollowingParsedSilenceRequirements({ solutionId, id }: { solutionId: Solution['id'], id: Requirement['id'] }): Promise>> { + // TODO: refactor to use the repository + async getFollowingParsedSilenceRequirements({ solutionId, id }: { solutionId: req.Solution['id'], id: req.Requirement['id'] }): Promise>> { if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const parsedRequirement = await this.getRequirementById({ solutionId, ReqClass: ParsedRequirement, id }) + const parsedRequirement = await this.getSolutionRequirementById({ solutionId, ReqClass: ParsedRequirement, id }) if (!parsedRequirement) throw new Error('Not Found: The ParsedRequirement does not exist.') // Get all unapproved requirements that follow from the specified ParsedRequirement - const requirements = await parsedRequirement.followedBy.loadItems({ + const requirements = await parsedRequirement.followedByIds.loadItems({ where: { isSilence: true } }) @@ -899,8 +574,9 @@ export class OrganizationInteractor { * @throws {Error} If the user is not a contributor of the organization or better * @throws {Error} If the Solution does not belong to the organization */ + // TODO: refactor to use the repository async parseRequirement({ solutionId, statement, parsingService }: { - solutionId: Solution['id'], + solutionId: req.Solution['id'], statement: string, // FIXME: This should no be the explicit service but a generic service parsingService: NaturalLanguageToRequirementService @@ -908,13 +584,13 @@ export class OrganizationInteractor { if (!this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') - const appUser = await this._user, + const appUser = await this._userId, em = this.getEntityManager(), solution = await this.getSolutionById(solutionId) const groupedResult = await parsingService.parse(statement) - const parsedRequirement = em.create(ParsedRequirement, { + const parsedRequirement = em.create(req.ParsedRequirement, { name: '{LLM Parsed Requirement}', description: statement, createdBy: appUser, @@ -926,7 +602,7 @@ export class OrganizationInteractor { em.create(Belongs, { left: parsedRequirement, right: solution }) // FIXME: need a better type than 'any' - const addSolReq = (ReqClass: typeof Requirement, props: any) => { + const addSolReq = (ReqClass: typeof req.Requirement, props: any) => { const req = em.create(ReqClass, { ...props, isSilence: true, @@ -941,32 +617,32 @@ export class OrganizationInteractor { } // FIXME: need a better type than 'any' - const addParsedReq = (ReqClass: typeof Requirement, props: any) => { + const addParsedReq = (ReqClass: typeof req.Requirement, props: any) => { const req = addSolReq(ReqClass, props) em.create(Follows, { left: req, right: parsedRequirement }) return req }; - groupedResult.Assumption?.forEach((item) => addParsedReq(Assumption, item)); - groupedResult.Constraint?.forEach((item) => addParsedReq(Constraint, item)); - groupedResult.Effect?.forEach((item) => addParsedReq(Effect, item)); - groupedResult.EnvironmentComponent?.forEach((item) => addParsedReq(EnvironmentComponent, item)); - groupedResult.FunctionalBehavior?.forEach((item) => addParsedReq(FunctionalBehavior, item)); - groupedResult.GlossaryTerm?.forEach((item) => addParsedReq(GlossaryTerm, item)); - groupedResult.Invariant?.forEach((item) => addParsedReq(Invariant, item)); - groupedResult.Justification?.forEach((item) => addParsedReq(Justification, item)); - groupedResult.Limit?.forEach((item) => addParsedReq(Limit, item)); - groupedResult.NonFunctionalBehavior?.forEach((item) => addParsedReq(NonFunctionalBehavior, item)); - groupedResult.Obstacle?.forEach((item) => addParsedReq(Obstacle, item)); - groupedResult.Outcome?.forEach((item) => addParsedReq(Outcome, item)); - groupedResult.Person?.forEach((item) => addParsedReq(Person, item)); - groupedResult.Stakeholder?.forEach((item) => addParsedReq(Stakeholder, item)); - groupedResult.SystemComponent?.forEach((item) => addParsedReq(SystemComponent, item)); + groupedResult.Assumption?.forEach((item) => addParsedReq(req.Assumption, item)); + groupedResult.Constraint?.forEach((item) => addParsedReq(req.Constraint, item)); + groupedResult.Effect?.forEach((item) => addParsedReq(req.Effect, item)); + groupedResult.EnvironmentComponent?.forEach((item) => addParsedReq(req.EnvironmentComponent, item)); + groupedResult.FunctionalBehavior?.forEach((item) => addParsedReq(req.FunctionalBehavior, item)); + groupedResult.GlossaryTerm?.forEach((item) => addParsedReq(req.GlossaryTerm, item)); + groupedResult.Invariant?.forEach((item) => addParsedReq(req.Invariant, item)); + groupedResult.Justification?.forEach((item) => addParsedReq(req.Justification, item)); + groupedResult.Limit?.forEach((item) => addParsedReq(req.Limit, item)); + groupedResult.NonFunctionalBehavior?.forEach((item) => addParsedReq(req.NonFunctionalBehavior, item)); + groupedResult.Obstacle?.forEach((item) => addParsedReq(req.Obstacle, item)); + groupedResult.Outcome?.forEach((item) => addParsedReq(req.Outcome, item)); + groupedResult.Person?.forEach((item) => addParsedReq(req.Person, item)); + groupedResult.Stakeholder?.forEach((item) => addParsedReq(req.Stakeholder, item)); + groupedResult.SystemComponent?.forEach((item) => addParsedReq(req.SystemComponent, item)); groupedResult.UseCase?.forEach((item) => { - addParsedReq(UseCase, { + addParsedReq(req.UseCase, { extensions: item.extensions, - outcome: addSolReq(Outcome, { + outcome: addSolReq(req.Outcome, { name: item.name, description: item.outcome }), @@ -975,47 +651,47 @@ export class OrganizationInteractor { scope: item.scope, name: item.name, description: '', - priority: item.moscowPriority as MoscowPriority ?? MoscowPriority.MUST, + priority: item.moscowPriority as req.MoscowPriority ?? req.MoscowPriority.MUST, // triggerId: undefined, - precondition: addSolReq(Assumption, { + precondition: addSolReq(req.Assumption, { name: item.name, description: item.precondition }), - successGuarantee: addSolReq(Effect, { + successGuarantee: addSolReq(req.Effect, { name: item.name, description: item.successGuarantee }), - primaryActor: addSolReq(Stakeholder, { + primaryActor: addSolReq(req.Stakeholder, { name: item.primaryActor, availability: 50, influence: 50, description: '', - category: StakeholderCategory.KEY_STAKEHOLDER, - segmentation: StakeholderSegmentation.VENDOR, + category: req.StakeholderCategory.KEY_STAKEHOLDER, + segmentation: req.StakeholderSegmentation.VENDOR, }) }) }); (groupedResult.UserStory ?? []).forEach((item) => { - addParsedReq(UserStory, { - priority: item.moscowPriority as MoscowPriority ?? MoscowPriority.MUST, + addParsedReq(req.UserStory, { + priority: item.moscowPriority as req.MoscowPriority ?? req.MoscowPriority.MUST, name: item.name, description: '', - functionalBehavior: addSolReq(FunctionalBehavior, { - priority: item.moscowPriority as MoscowPriority ?? MoscowPriority.MUST, + functionalBehavior: addSolReq(req.FunctionalBehavior, { + priority: item.moscowPriority as req.MoscowPriority ?? req.MoscowPriority.MUST, name: item.functionalBehavior, description: item.functionalBehavior }), - outcome: addSolReq(Outcome, { + outcome: addSolReq(req.Outcome, { name: item.outcome, description: item.outcome }), - primaryActor: addSolReq(Stakeholder, { + primaryActor: addSolReq(req.Stakeholder, { name: item.role, availability: 50, influence: 50, description: '', - category: StakeholderCategory.KEY_STAKEHOLDER, - segmentation: StakeholderSegmentation.VENDOR + category: req.StakeholderCategory.KEY_STAKEHOLDER, + segmentation: req.StakeholderSegmentation.VENDOR }) }) }); diff --git a/components/XDataTable.vue b/components/XDataTable.vue index 2d21571f..d7797ad0 100644 --- a/components/XDataTable.vue +++ b/components/XDataTable.vue @@ -3,7 +3,7 @@ import type Dialog from 'primevue/dialog' import type DataTable from 'primevue/datatable' import { FilterMatchMode } from 'primevue/api'; import type { AuditLogViewModel } from '#shared/models'; -import { camelCaseToTitle } from '#shared/utils'; +import { camelCaseToTitleCase } from '#shared/utils'; export type RequirementFieldType = { type: 'requirement', options: { id: string, name: string }[] } @@ -186,7 +186,7 @@ const onEditDialogCancel = () => { :globalFilterFields="Object.keys(props.datasource?.[0] ?? {})" :loading="props.loading" stripedRows> + :key="key" :field="key" :header="camelCaseToTitleCase(key)" sortable>