diff --git a/application/OrganizationInteractor.ts b/application/OrganizationInteractor.ts index eef61c13..15e362e8 100644 --- a/application/OrganizationInteractor.ts +++ b/application/OrganizationInteractor.ts @@ -1,11 +1,10 @@ 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 { QueryOrder, type ChangeSetType, type EntityManager } from "@mikro-orm/core"; +import { Collection, QueryOrder, type ChangeSetType, type EntityManager } from "@mikro-orm/core"; import { AppUserOrganizationRole, AppRole, AppUser, AuditLog } from "~/domain/application"; -import { Belongs } from "~/domain/relations/Belongs.js"; import { validate } from 'uuid' -import { Follows } from "~/domain/relations"; import type NaturalLanguageToRequirementService from "~/server/data/services/NaturalLanguageToRequirementService"; import { groupBy, slugify } from "#shared/utils"; +import { Belongs, Follows } from "~/domain/relations"; type OrganizationInteractorConstructor = { entityManager: EntityManager, @@ -53,17 +52,16 @@ export class OrganizationInteractor { * @param prefix - The prefix for the requirement id. Ex: 'P.1.' */ private async _getNextReqId(solution: Solution, prefix: R['reqIdPrefix']): Promise['reqId']> { - const em = this.getEntityManager(), - entityCount = await em.count(Belongs, { - left: { reqId: { $like: `${prefix}%` } }, - right: solution - }) + const entityCount = await solution.contains.loadCount({ + where: { reqId: { $like: `${prefix}%` } } + }); return `${prefix}${entityCount + 1}` } /** * Create a new entity manager fork + * * @returns The entity manager */ // TODO: this will likely be moved into a Repository class in the future @@ -78,8 +76,8 @@ export class OrganizationInteractor { * @param props.ReqClass - The Constructor of the requirement to add * @param props.reqProps - The requirement data to add * @returns The id of the new requirement + * @throws {Error} If the organization 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 user is not a reader of the organization or better * @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 @@ -94,24 +92,26 @@ export class OrganizationInteractor { if (!this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') + const solution = await this.getSolutionById(solutionId) + + if (!solution) + throw new Error('Not Found: The solution does not exist.') + const em = this.getEntityManager(), appUser = await this._user, - { left: solution } = await em.findOneOrFail(Belongs, { - left: { id: solutionId, req_type: ReqType.SOLUTION }, - right: await this._organization - }) as unknown as { left: Solution }, // If the entity is silent, do not assign a reqId reqId = reqProps.isSilence ? undefined : await this._getNextReqId(solution, ReqClass.reqIdPrefix); // if a property is a uuid, assert that it belongs to the solution - for (const [key, value] of Object.entries(reqProps)) { - if (validate(value) && !['id', 'createdBy', 'modifiedBy'].includes(key)) - await em.findOneOrFail(Belongs, { left: value, right: 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.`) + } } - console.log('reqProps', reqProps) - const newRequirement = em.create(ReqClass, { ...reqProps, reqId, @@ -148,13 +148,20 @@ export class OrganizationInteractor { const em = this.getEntityManager(), solution = await this.getSolutionById(props.solutionId), - { left: requirement } = await em.findOneOrFail(Belongs, { - left: { - id: props.id, - req_type: props.ReqClass.req_type - }, - right: solution - }, { populate: ['left'] }) as unknown as { left: InstanceType } + 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]) // TODO: decrement the reqId of all requirements that have a reqId greater than the deleted requirement @@ -181,12 +188,13 @@ export class OrganizationInteractor { if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - solution = await this.getSolutionById(props.solutionId), - { left: requirement } = await em.findOneOrFail(Belongs, { - left: { id: props.id, req_type: props.ReqClass.req_type }, - right: solution - }, { populate: ['left'] }) as unknown as { left: InstanceType } + 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 } @@ -208,15 +216,13 @@ export class OrganizationInteractor { if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - solution = await this.getSolutionById(props.solutionId), - requirements = (await em.find(Belongs, { - left: { + const solution = await this.getSolutionById(props.solutionId), + requirements = (await solution.contains.loadItems>({ + where: { req_type: props.ReqClass.req_type, ...props.query - }, - right: solution - }, { populate: ['left'] })).map((req) => req.left as InstanceType) + } + })) as InstanceType[] return requirements } @@ -247,9 +253,12 @@ export class OrganizationInteractor { const em = this.getEntityManager(), solution = await this.getSolutionById(props.solutionId), - { left: requirement } = await em.findOneOrFail(Belongs, { - left: props.id, right: solution - }, { populate: ['left'] }) as unknown as { left: InstanceType } + 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({ @@ -282,14 +291,11 @@ export class OrganizationInteractor { const em = this.getEntityManager(), appUser = await em.findOne(AppUser, { email }), - organization = await this._organization + organization = await this.getOrganization() if (!appUser) throw new Error('Not Found: The app user with the given email does not exist.') - if (!organization) - throw new Error('Not Found: The organization does not exist.') - const existingOrgAppUserRole = await em.findOne(AppUserOrganizationRole, { appUser, organization @@ -313,7 +319,7 @@ 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._organization, + const organization = await this.getOrganization(), em = this.getEntityManager(), { appUser, role } = (await em.findOneOrFail(AppUserOrganizationRole, { appUser: id, @@ -339,7 +345,7 @@ export class OrganizationInteractor { */ async deleteAppUser(id: AppUser['id']): Promise { const em = this.getEntityManager(), - organization = await this._organization, + organization = await this.getOrganization(), appUser = await this._user, [targetAppUserRole, orgAdminCount] = await Promise.all([ em.findOne(AppUserOrganizationRole, { @@ -377,7 +383,7 @@ export class OrganizationInteractor { */ async updateAppUserRole(id: AppUser['id'], role: AppRole): Promise { const em = this.getEntityManager(), - organization = await this._organization, + organization = await this.getOrganization(), appUser = await this._user, [targetAppUserRole, orgAdminCount] = await Promise.all([ em.findOne(AppUserOrganizationRole, { @@ -419,7 +425,7 @@ export class OrganizationInteractor { throw new Error('Forbidden: You do not have permission to perform this action') const em = this.getEntityManager(), - organization = await this._organization, + organization = await this.getOrganization(), appUserOrganizationRoles = await em.findAll(AppUserOrganizationRole, { where: { organization }, populate: ['appUser'] @@ -440,7 +446,7 @@ export class OrganizationInteractor { */ async isOrganizationAdmin(): Promise { const appUser = await this._user, - organization = await this._organization + organization = await this.getOrganization() if (appUser.isSystemAdmin) return true @@ -458,7 +464,7 @@ export class OrganizationInteractor { */ async isOrganizationContributor(): Promise { const appUser = await this._user, - organization = await this._organization + organization = await this.getOrganization() if (appUser.isSystemAdmin) return true @@ -475,7 +481,7 @@ 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._organization, + const organization = await this.getOrganization(), appUser = await this._user if (appUser.isSystemAdmin) return true @@ -528,12 +534,9 @@ export class OrganizationInteractor { * @throws {Error} If the organization does not exist */ async addSolution({ name, description }: Pick): Promise { - const organization = await this._organization, + const organization = await this.getOrganization(), createdBy = await this._user - if (!organization) - throw new Error('Not Found: The organization does not exist.') - if (!await this.isOrganizationAdmin()) throw new Error('Forbidden: You do not have permission to perform this action') @@ -554,13 +557,13 @@ export class OrganizationInteractor { await this.addRequirement({ solutionId: newSolution.id, ReqClass: Outcome, - reqProps: { name: 'G.1', description: 'Context and Objective', isSilence: false } + reqProps: { name: 'G.1', description: 'Context and Objective', isSilence: false } as ConstructorParameters[0] }) await this.addRequirement({ solutionId: newSolution.id, ReqClass: Obstacle, - reqProps: { name: 'G.2', description: 'Situation', isSilence: false } + reqProps: { name: 'G.2', description: 'Situation', isSilence: false } as ConstructorParameters[0] }) return newSolution @@ -575,29 +578,21 @@ export class OrganizationInteractor { * @throws {Error} If the organization does not exist */ async findSolutions(query: Partial = {}): Promise { - const organization = await this._organization - - if (!organization) - throw new Error('Not Found: The organization does not exist.') + const organization = await this.getOrganization() if (!await this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - solutions = (await em.find(Belongs, { - left: { - req_type: ReqType.SOLUTION, - // 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) - }, - right: { id: organization.id } - }, { populate: ['left'] })).map((sol) => sol.left as Solution) - - if (solutions.length === 0) - return [] + 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 + } + }) return solutions } @@ -615,11 +610,13 @@ export class OrganizationInteractor { if (!await this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - { left: solution } = await em.findOneOrFail(Belongs, { - left: { id: solutionId, req_type: ReqType.SOLUTION }, - right: await this._organization - }, { populate: ['left'] }) as unknown as { left: Solution } + 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 } @@ -637,11 +634,13 @@ export class OrganizationInteractor { if (!await this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - { left: solution } = await em.findOneOrFail(Belongs, { - left: { slug, req_type: ReqType.SOLUTION } as Solution, - right: await this._organization - }, { populate: ['left'] }) as unknown as { left: Solution } + 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 } @@ -659,10 +658,10 @@ export class OrganizationInteractor { throw new Error('Forbidden: You do not have permission to perform this action') const em = this.getEntityManager(), - { left: solution } = await em.findOneOrFail(Belongs, { - left: { slug, req_type: ReqType.SOLUTION } as Solution, - right: await this._organization - }) as unknown as { left: Solution } + solution = await this.getSolutionBySlug(slug) + + if (!solution) + throw new Error('Not Found: The solution does not exist.') await em.removeAndFlush(solution) } @@ -681,10 +680,10 @@ export class OrganizationInteractor { throw new Error('Forbidden: You do not have permission to perform this action') const em = this.getEntityManager(), - { left: solution } = await em.findOneOrFail(Belongs, { - left: { slug, req_type: ReqType.SOLUTION } as Solution, - right: await this._organization - }) as unknown as { left: Solution } + solution = await this.getSolutionBySlug(slug) + + if (!solution) + throw new Error('Not Found: The solution does not exist.') solution.assign({ name: props.name ?? solution.name, @@ -735,7 +734,7 @@ export class OrganizationInteractor { * Returns the organization that the user is associated with * * @returns The organization - * @throws {Error} If the user is not associated with an organization + * @throws {Error} If the organization does not exist */ async getOrganization(): Promise { if (!this._organization) @@ -751,10 +750,7 @@ export class OrganizationInteractor { */ async deleteOrganization(): Promise { const em = this.getEntityManager(), - organization = await this._organization - - if (!organization) - throw new Error('Not Found: The organization does not exist.') + organization = await this.getOrganization() if (!await this.isOrganizationAdmin()) throw new Error('Forbidden: You do not have permission to perform this action') @@ -781,10 +777,7 @@ export class OrganizationInteractor { */ async updateOrganization(props: Pick, 'name' | 'description'>): Promise { const em = this.getEntityManager(), - organization = await this._organization - - if (!organization) - throw new Error('Not Found: The organization does not exist.') + organization = await this.getOrganization() if (!await this.isOrganizationContributor()) throw new Error('Forbidden: You do not have permission to perform this action') @@ -841,7 +834,7 @@ export class OrganizationInteractor { entity: string } - const conn = this._entityManager.getConnection(), + 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 @@ -882,47 +875,18 @@ export class OrganizationInteractor { if (!this.isOrganizationReader()) throw new Error('Forbidden: You do not have permission to perform this action') - const em = this.getEntityManager(), - solution = await this.getSolutionById(solutionId), - // Check if the ParsedRequirement belongs to the solution - { left: parsedRequirement } = (await em.findOneOrFail(Belongs, { - left: { id: id, req_type: ReqType.PARSED_REQUIREMENT }, - right: solution - }, { populate: ['left'] })) as { left: ParsedRequirement }, - // Get all unapproved requirements that follow from the specified ParsedRequirement - requirements = (await em.find(Follows, { - right: parsedRequirement, - left: { isSilence: true } - }, { populate: ['left'] })).map(f => f.left as Requirement), - // Group the results by the requirement type - groupedResult = groupBy(requirements, ({ req_type }) => req_type) - - return groupedResult - } + const parsedRequirement = await this.getRequirementById({ solutionId, ReqClass: ParsedRequirement, id }) - /** - * Return all ParsedRequirements for a Solution. - * @param solutionId The id of the solution to get the ParsedRequirements for - * @returns The ParsedRequirements for the Solution - * @throws {Error} If the user is not a reader of the organization or better - * @throws {Error} If the Solution does not belong to the organization - */ - async getParsedRequirements(solutionId: Solution['id']): Promise { - if (!this.isOrganizationReader()) - throw new Error('Forbidden: You do not have permission to perform this action') + if (!parsedRequirement) + throw new Error('Not Found: The ParsedRequirement does not exist.') - const em = this.getEntityManager(), - // Check if the Solution belongs to the organization - { left: solution } = (await em.findOneOrFail(Belongs, { - left: { id: solutionId, req_type: ReqType.SOLUTION }, - right: await this._organization - }, { populate: ['left'] })) as unknown as { left: Solution }, - parsedRequirements = (await em.find(Belongs, { - left: { req_type: ReqType.PARSED_REQUIREMENT }, - right: solution - }, { populate: ['left'] })).map((pr) => pr.left as ParsedRequirement) - - return parsedRequirements + // Get all unapproved requirements that follow from the specified ParsedRequirement + const requirements = await parsedRequirement.followedBy.loadItems({ + where: { isSilence: true } + }) + + // Group the results by the requirement type + return groupBy(requirements, ({ req_type }) => req_type) } /** @@ -946,10 +910,7 @@ export class OrganizationInteractor { const appUser = await this._user, em = this.getEntityManager(), - { left: solution } = (await em.findOneOrFail(Belongs, { - left: { id: solutionId, req_type: ReqType.SOLUTION }, - right: await this._organization - }, { populate: ['left'] })) as unknown as { left: Solution } + solution = await this.getSolutionById(solutionId) const groupedResult = await parsingService.parse(statement) @@ -962,7 +923,7 @@ export class OrganizationInteractor { isSilence: true }) - em.create(Belongs, { left: parsedRequirement, right: solution }); + em.create(Belongs, { left: parsedRequirement, right: solution }) // FIXME: need a better type than 'any' const addSolReq = (ReqClass: typeof Requirement, props: any) => { @@ -974,7 +935,7 @@ export class OrganizationInteractor { createdBy: appUser }) - em.create(Belongs, { left: req, right: solution }); + em.create(Belongs, { left: req, right: solution }) return req } @@ -982,7 +943,8 @@ export class OrganizationInteractor { // FIXME: need a better type than 'any' const addParsedReq = (ReqClass: typeof Requirement, props: any) => { const req = addSolReq(ReqClass, props) - em.create(Follows, { left: req, right: parsedRequirement }); + em.create(Follows, { left: req, right: parsedRequirement }) + return req }; diff --git a/domain/relations/RequirementRelation.ts b/domain/relations/RequirementRelation.ts index b6f59f5d..29a1b821 100644 --- a/domain/relations/RequirementRelation.ts +++ b/domain/relations/RequirementRelation.ts @@ -1,35 +1,26 @@ -import { v7 as uuidv7 } from 'uuid'; -import { BaseEntity, Cascade, Entity, ManyToOne, Property } from "@mikro-orm/core"; +import { BaseEntity, Entity, ManyToOne } from "@mikro-orm/core"; import { Requirement } from '../requirements/Requirement.js' -import { type Properties } from '../types/index.js'; /** * Relations between requirements */ @Entity({ abstract: true, discriminatorColumn: 'rel_type' }) export abstract class RequirementRelation extends BaseEntity { - constructor(props: Properties>) { + constructor({ left, right }: { left: Requirement, right: Requirement }) { super() - this.id = uuidv7(); - this.left = props.left; - this.right = props.right; + this.left = left; + this.right = right; } - /** - * The unique identifier of the RequirementRelation - */ - @Property({ type: 'uuid', primary: true }) - id: string; - /** * The left-hand side of the relation */ - @ManyToOne({ entity: () => Requirement, cascade: [Cascade.REMOVE] }) + @ManyToOne({ primary: true, entity: () => Requirement }) left: Requirement /** * The right-hand side of the relation */ - @ManyToOne({ entity: () => Requirement, cascade: [Cascade.REMOVE] }) + @ManyToOne({ primary: true, entity: () => Requirement }) right: Requirement } \ No newline at end of file diff --git a/domain/requirements/Actor.ts b/domain/requirements/Actor.ts index 7a1b6876..51f19f4b 100644 --- a/domain/requirements/Actor.ts +++ b/domain/requirements/Actor.ts @@ -5,7 +5,7 @@ import { ReqType } from "./ReqType.js"; /** * A part of a Project, Environment, System, or Goals that may affect or be affected by the associated entities */ -@Entity({ abstract: true, discriminatorValue: ReqType.ACTOR }) -export abstract class Actor extends Requirement { +@Entity({ discriminatorValue: ReqType.ACTOR }) +export class Actor extends Requirement { static override req_type: ReqType = ReqType.ACTOR; -} +} \ No newline at end of file diff --git a/domain/requirements/Behavior.ts b/domain/requirements/Behavior.ts index c09401d5..fd9feffa 100644 --- a/domain/requirements/Behavior.ts +++ b/domain/requirements/Behavior.ts @@ -1,17 +1,16 @@ import { Entity, Enum } from "@mikro-orm/core"; import { Requirement } from "./Requirement.js"; import { MoscowPriority } from "./MoscowPriority.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** * Property of the operation of the system */ -@Entity({ abstract: true, discriminatorValue: ReqType.BEHAVIOR }) +@Entity({ discriminatorValue: ReqType.BEHAVIOR }) export class Behavior extends Requirement { static override req_type: ReqType = ReqType.BEHAVIOR; - constructor({ priority, ...rest }: Properties>) { + constructor({ priority, ...rest }: ConstructorParameters[0] & Pick) { super(rest); this.priority = priority; } diff --git a/domain/requirements/Component.ts b/domain/requirements/Component.ts index 2b37f8d4..0fb24a72 100644 --- a/domain/requirements/Component.ts +++ b/domain/requirements/Component.ts @@ -5,7 +5,7 @@ import { ReqType } from "./ReqType.js"; /** * Idenfitication of a part (of the Project, Environment, Goals, or System) */ -@Entity({ abstract: true, discriminatorValue: ReqType.COMPONENT }) -export abstract class Component extends Actor { +@Entity({ discriminatorValue: ReqType.COMPONENT }) +export class Component extends Actor { static override req_type: ReqType = ReqType.COMPONENT; } diff --git a/domain/requirements/Constraint.ts b/domain/requirements/Constraint.ts index 01e5e45f..6294d5bc 100644 --- a/domain/requirements/Constraint.ts +++ b/domain/requirements/Constraint.ts @@ -1,7 +1,6 @@ import { Entity, Enum } from '@mikro-orm/core'; import { Requirement } from './Requirement.js'; import { ConstraintCategory } from './ConstraintCategory.js'; -import { type Properties } from '../types/index.js'; import { ReqType } from './ReqType.js'; /** @@ -12,7 +11,7 @@ export class Constraint extends Requirement { static override reqIdPrefix = 'E.3.' as const; static override req_type = ReqType.CONSTRAINT; - constructor({ category, ...rest }: Properties>) { + constructor({ category, ...rest }: ConstructorParameters[0] & Pick) { super(rest); this.category = category; } diff --git a/domain/requirements/Epic.ts b/domain/requirements/Epic.ts index 623dd240..31db5665 100644 --- a/domain/requirements/Epic.ts +++ b/domain/requirements/Epic.ts @@ -1,7 +1,6 @@ import { Entity, ManyToOne } from "@mikro-orm/core"; import { Scenario } from "./Scenario.js"; import { ReqType } from "./ReqType.js"; -import type { Properties } from "../types/index.js"; import { FunctionalBehavior } from "./FunctionalBehavior.js"; /** @@ -13,7 +12,7 @@ export class Epic extends Scenario { static override reqIdPrefix = 'G.5.' as const; static override req_type = ReqType.EPIC; - constructor(props: Properties>) { + constructor(props: ConstructorParameters[0] & Pick) { super(props); this.functionalBehavior = props.functionalBehavior; } diff --git a/domain/requirements/Example.ts b/domain/requirements/Example.ts index 6e4c058f..98d95d80 100644 --- a/domain/requirements/Example.ts +++ b/domain/requirements/Example.ts @@ -5,7 +5,7 @@ import { ReqType } from "./ReqType.js"; /** * Illustration of behavior through a usage scenario */ -@Entity({ abstract: true, discriminatorValue: ReqType.EXAMPLE }) -export abstract class Example extends Behavior { +@Entity({ discriminatorValue: ReqType.EXAMPLE }) +export class Example extends Behavior { static override req_type: ReqType = ReqType.EXAMPLE; -} +} \ No newline at end of file diff --git a/domain/requirements/Functionality.ts b/domain/requirements/Functionality.ts index 14991964..50a006f0 100644 --- a/domain/requirements/Functionality.ts +++ b/domain/requirements/Functionality.ts @@ -5,7 +5,7 @@ import { ReqType } from "./ReqType.js"; /** * Functionality describes what system will do and how it will do it. */ -@Entity({ abstract: true, discriminatorValue: ReqType.FUNCTIONALITY }) -export abstract class Functionality extends Behavior { +@Entity({ discriminatorValue: ReqType.FUNCTIONALITY }) +export class Functionality extends Behavior { static override req_type: ReqType = ReqType.FUNCTIONALITY; } \ No newline at end of file diff --git a/domain/requirements/Goal.ts b/domain/requirements/Goal.ts index 5bd4273e..803f6c54 100644 --- a/domain/requirements/Goal.ts +++ b/domain/requirements/Goal.ts @@ -7,7 +7,7 @@ import { ReqType } from "./ReqType.js"; * an objective of the project or system, in terms * of their desired effect on the environment */ -@Entity({ abstract: true, discriminatorValue: ReqType.GOAL }) -export abstract class Goal extends Requirement { +@Entity({ discriminatorValue: ReqType.GOAL }) +export class Goal extends Requirement { static override req_type: ReqType = ReqType.GOAL; } \ No newline at end of file diff --git a/domain/requirements/Invariant.ts b/domain/requirements/Invariant.ts index 6de2729f..e872ade1 100644 --- a/domain/requirements/Invariant.ts +++ b/domain/requirements/Invariant.ts @@ -1,6 +1,5 @@ import { Entity } from "@mikro-orm/core"; import { Requirement } from "./Requirement.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** @@ -13,7 +12,7 @@ export class Invariant extends Requirement { static override reqIdPrefix = 'E.6.' as const; static override req_type: ReqType = ReqType.INVARIANT; - constructor(props: Properties>) { + constructor(props: ConstructorParameters[0]) { super(props); this.req_type = ReqType.INVARIANT; } diff --git a/domain/requirements/Organization.ts b/domain/requirements/Organization.ts index 8984a92d..cc330a52 100644 --- a/domain/requirements/Organization.ts +++ b/domain/requirements/Organization.ts @@ -1,7 +1,6 @@ import { Entity, Property } from "@mikro-orm/core"; import { slugify } from "../../shared/utils/slugify.js"; import { Requirement } from "./Requirement.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** @@ -11,7 +10,7 @@ import { ReqType } from "./ReqType.js"; export class Organization extends Requirement { static override req_type: ReqType = ReqType.ORGANIZATION - constructor(props: Properties>) { + constructor(props: ConstructorParameters[0]) { super(props) this._slug = slugify(props.name); } diff --git a/domain/requirements/Person.ts b/domain/requirements/Person.ts index 02b5bb6c..57332dc0 100644 --- a/domain/requirements/Person.ts +++ b/domain/requirements/Person.ts @@ -1,6 +1,5 @@ import { Entity, Property } from "@mikro-orm/core"; import { Actor } from "./Actor.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** @@ -11,7 +10,7 @@ export class Person extends Actor { static override req_type: ReqType = ReqType.PERSON; static override reqIdPrefix = 'P.1.' as const; - constructor({ email, ...rest }: Properties>) { + constructor({ email, ...rest }: ConstructorParameters[0] & Pick) { super(rest); this.email = email; } diff --git a/domain/requirements/Requirement.ts b/domain/requirements/Requirement.ts index 4a4db074..f85af6db 100644 --- a/domain/requirements/Requirement.ts +++ b/domain/requirements/Requirement.ts @@ -1,5 +1,5 @@ import { v7 as uuidv7 } from 'uuid'; -import { BaseEntity, Entity, Enum, ManyToOne, OptionalProps, Property } from '@mikro-orm/core'; +import { BaseEntity, Cascade, Collection, Entity, Enum, ManyToMany, ManyToOne, OptionalProps, Property } from '@mikro-orm/core'; import { type Properties } from '../types/index.js'; import { ReqType } from './ReqType.js'; import { AppUser } from '../application/AppUser.js'; @@ -23,6 +23,10 @@ export abstract class Requirement extends BaseEntity { this.isSilence = props.isSilence; this.createdBy = props.createdBy; this._reqId = props.reqId; + + // The ORM can pass extra properties to the constructor + if (Object.values(props).some((value) => (value as any) instanceof Collection)) + throw new Error('Collections cannot be passed to the Requirement constructor') } // This fixes the issue with em.create not honoring the constructor signature @@ -103,4 +107,204 @@ export abstract class Requirement extends BaseEntity { */ @Property({ type: 'boolean', default: false }) isSilence: boolean; + + /////////////////////////////////////////////////////////////////////////// + ////////////////////// Relations ///////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////// + + /** + * this ⊆ other + * + * this is a sub-requirement of other; textually included + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Belongs', inversedBy: (r) => r.contains }) + belongs = new Collection(this); + + /** + * The inverse of {@link belongs} + * + * This is the set of requirements that are sub-requirements of this requirement + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Belongs', mappedBy: (r) => r.belongs }) + contains = new Collection(this); + + /** + * this → other + * + * Meta-requirement this applies to other + */ + // TODO: need to enforce that this is a MetaRequirement + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Characterizes', inversedBy: (r) => r.characterizedBy }) + characterizes = new Collection(this); + + /** + * The inverse of {@link characterizes} + */ + // TODO: need to enforce that other is a MetaRequirement + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Characterizes', mappedBy: (r) => r.characterizes }) + characterizedBy = new Collection(this); + + /** + * this ▸ other + * + * Constraint this applies to other + */ + // TODO: need to enforce that this is a Constraint + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Constrains', inversedBy: (r) => r.constrainedBy }) + constrains = new Collection(this); + + /** + * The inverse of {@link constrains} + */ + // TODO: need to enforce that other is a Constraint + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Constrains', mappedBy: (r) => r.constrains }) + constrainedBy = new Collection(this); + + /** + * this ⊕ other + * + * Properties specified by this and other cannot both hold + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Contradicts', inversedBy: (r) => r.contradictedBy }) + contradicts = new Collection(this); + + /** + * The inverse of {@link contradicts} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Contradicts', mappedBy: (r) => r.contradicts }) + contradictedBy = new Collection(this); + + /** + * this » other + * + * this adds detail to properties of other + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Details', inversedBy: (r) => r.detailedBy }) + details = new Collection(this); + + /** + * The inverse of {@link details} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Details', mappedBy: (r) => r.details }) + detailedBy = new Collection(this); + + /** + * this || other + * + * this and other are unrelated + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Disjoins', inversedBy: (r) => r.disjoinedBy }) + disjoins = new Collection(this); + + /** + * The inverse of {@link disjoins} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Disjoins', mappedBy: (r) => r.disjoins }) + disjoinedBy = new Collection(this); + + /** + * this ≡ other + * + * this ⇔ other, and this has the same type as other. + * In other words, this and other are redundant. + * Same properties, same type (notation-wise, this ≡ other) + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Duplicates', inversedBy: (r) => r.duplicatedBy }) + duplicates = new Collection(this); + + /** + * The inverse of {@link duplicates} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Duplicates', mappedBy: (r) => r.duplicates }) + duplicatedBy = new Collection(this); + + /** + * this \\ other + * + * this specifies an exception to the property specified by other. + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Excepts', inversedBy: (r) => r.exceptedBy }) + excepts = new Collection(this); + + /** + * The inverse of {@link excepts} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Excepts', mappedBy: (r) => r.excepts }) + exceptedBy = new Collection(this); + + /** + * this ≅ other + * + * this ⇔ other, and this has a different type from other. + * In other words, other introduces no new property but helps understand this better. + * Same properties, different type (notation-wise) + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Explains', inversedBy: (r) => r.explainedBy }) + explains = new Collection(this); + + /** + * The inverse of {@link explains} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Explains', mappedBy: (r) => r.explains }) + explainedBy = new Collection(this); + + /** + * this > other + * + * aka "refines". + * this assumes other and specifies a property that other does not. + * this adds to properties of other + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Extends', inversedBy: (r) => r.extendedBy }) + extends = new Collection(this); + + /** + * The inverse of {@link extends} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Extends', mappedBy: (r) => r.extends }) + extendedBy = new Collection(this); + + /** + * this ⊣ other + * + * this is a consequence of the property specified by other + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Follows', inversedBy: (r) => r.followedBy }) + follows = new Collection(this); + + /** + * The inverse of {@link follows} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Follows', mappedBy: (r) => r.follows }) + followedBy = new Collection(this); + + /** + * this ⇔ other + * + * this specifies the same property as other + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Repeats', inversedBy: (r) => r.repeatedBy }) + repeats = new Collection(this); + + /** + * The inverse of {@link repeats} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Repeats', mappedBy: (r) => r.repeats }) + repeatedBy = new Collection(this); + + /** + * this ∩ other + * + * this' ⇔ other' for some sub-requirements this' and other' of this and other. + * (Involve Repeats). + * Some subrequirement is in common between this and other. + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Shares', inversedBy: (r) => r.sharedBy }) + shares = new Collection(this); + + /** + * The inverse of {@link shares} + */ + @ManyToMany({ entity: () => Requirement, pivotEntity: 'Shares', mappedBy: (r) => r.shares }) + sharedBy = new Collection(this); } \ No newline at end of file diff --git a/domain/requirements/Scenario.ts b/domain/requirements/Scenario.ts index 5481c99c..d3577e55 100644 --- a/domain/requirements/Scenario.ts +++ b/domain/requirements/Scenario.ts @@ -1,7 +1,6 @@ import { Entity, ManyToOne } from "@mikro-orm/core"; import { Example } from "./Example.js"; import { Stakeholder } from "./Stakeholder.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; import { Outcome } from "./Outcome.js"; @@ -13,7 +12,7 @@ import { Outcome } from "./Outcome.js"; export abstract class Scenario extends Example { static override req_type: ReqType = ReqType.SCENARIO; - constructor({ primaryActor, outcome, ...rest }: Properties>) { + constructor({ primaryActor, outcome, ...rest }: ConstructorParameters[0] & Pick) { super(rest); this.primaryActor = primaryActor; this.outcome = outcome; diff --git a/domain/requirements/Silence.ts b/domain/requirements/Silence.ts index f254fe5b..1ccb7d91 100644 --- a/domain/requirements/Silence.ts +++ b/domain/requirements/Silence.ts @@ -1,16 +1,15 @@ import { Entity } from "@mikro-orm/core"; import { Requirement } from "./Requirement.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** - * Propery that is not in requirements but should be + * Property that is not in requirements but should be */ @Entity({ discriminatorValue: ReqType.SILENCE }) export class Silence extends Requirement { static override req_type: ReqType = ReqType.SILENCE; - constructor(props: Properties>) { + constructor(props: Omit[0], 'isSilence'>) { super({ ...props, isSilence: true }); } } \ No newline at end of file diff --git a/domain/requirements/Solution.ts b/domain/requirements/Solution.ts index 44da9a31..f8d9a699 100644 --- a/domain/requirements/Solution.ts +++ b/domain/requirements/Solution.ts @@ -1,5 +1,4 @@ import { Entity, Property } from '@mikro-orm/core'; -import { type Properties } from '../types/index.js'; import { slugify } from '../../shared/utils/slugify.js'; import { Requirement } from './Requirement.js'; import { ReqType } from './ReqType.js'; @@ -11,7 +10,7 @@ import { ReqType } from './ReqType.js'; export class Solution extends Requirement { static override req_type: ReqType = ReqType.SOLUTION; - constructor(props: Properties>) { + constructor(props: ConstructorParameters[0]) { super(props); this._slug = slugify(props.name); } diff --git a/domain/requirements/Stakeholder.ts b/domain/requirements/Stakeholder.ts index c7fc1d16..cbd39a02 100644 --- a/domain/requirements/Stakeholder.ts +++ b/domain/requirements/Stakeholder.ts @@ -2,7 +2,6 @@ import { Entity, Enum, Property } from "@mikro-orm/core"; import { Component } from "./Component.js"; import { StakeholderCategory } from "./StakeholderCategory.js"; import { StakeholderSegmentation } from "./StakeholderSegmentation.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** @@ -13,7 +12,7 @@ export class Stakeholder extends Component { static override reqIdPrefix = 'G.7.' as const; static override req_type: ReqType = ReqType.STAKEHOLDER; - constructor(props: Properties>) { + constructor(props: ConstructorParameters[0] & Pick) { super(props); this.influence = props.influence; this.availability = props.availability; diff --git a/domain/requirements/TestCase.ts b/domain/requirements/TestCase.ts index a783215b..a0aa6094 100644 --- a/domain/requirements/TestCase.ts +++ b/domain/requirements/TestCase.ts @@ -5,7 +5,7 @@ import { ReqType } from "./ReqType.js"; /** * A TestCase is a specification of the inputs, execution conditions, * testing procedure, and expected results that define a single test to - * be executed to achieve a particular goal., + * be executed to achieve a particular goal. */ @Entity({ discriminatorValue: ReqType.TEST_CASE }) export class TestCase extends Example { diff --git a/domain/requirements/UseCase.ts b/domain/requirements/UseCase.ts index a470274d..4ba73bdf 100644 --- a/domain/requirements/UseCase.ts +++ b/domain/requirements/UseCase.ts @@ -2,7 +2,6 @@ import { Entity, ManyToOne, Property } from "@mikro-orm/core"; import { Assumption } from "./Assumption.js"; import { Effect } from "./Effect.js"; import { Scenario } from "./Scenario.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** @@ -14,7 +13,7 @@ export class UseCase extends Scenario { static override reqIdPrefix = 'S.4.' as const; static override req_type = ReqType.USE_CASE; - constructor(props: Properties>) { + constructor(props: ConstructorParameters[0] & Pick) { super(props); this.scope = props.scope; this.level = props.level; diff --git a/domain/requirements/UserStory.ts b/domain/requirements/UserStory.ts index c0978f18..e4b1e20e 100644 --- a/domain/requirements/UserStory.ts +++ b/domain/requirements/UserStory.ts @@ -1,7 +1,6 @@ import { Entity, ManyToOne } from "@mikro-orm/core"; import { FunctionalBehavior } from "./FunctionalBehavior.js"; import { Scenario } from "./Scenario.js"; -import { type Properties } from "../types/index.js"; import { ReqType } from "./ReqType.js"; /** @@ -18,9 +17,9 @@ export class UserStory extends Scenario { static override reqIdPrefix = 'S.4.' as const; static override req_type = ReqType.USER_STORY; - constructor(props: Properties>) { - super(props); - this.functionalBehavior = props.functionalBehavior; + constructor({ functionalBehavior, ...rest }: ConstructorParameters[0] & Pick) { + super(rest); + this.functionalBehavior = functionalBehavior; } override get reqId() { return super.reqId as `${typeof UserStory.reqIdPrefix}${number}` | undefined; } diff --git a/domain/requirements/index.ts b/domain/requirements/index.ts index 963afc9b..81b43d25 100644 --- a/domain/requirements/index.ts +++ b/domain/requirements/index.ts @@ -1,10 +1,10 @@ +export * from './Requirement.js'; export * from './Assumption.js'; export * from './ConstraintCategory.js'; export * from './Constraint.js'; export * from './Organization.js'; export * from './Solution.js'; export * from './ParsedRequirement.js'; -export * from './Requirement.js'; export * from './MetaRequirement.js'; export * from './Actor.js'; export * from './Component.js'; diff --git a/domain/types/index.ts b/domain/types/index.ts index af88c1f4..d5cb1308 100644 --- a/domain/types/index.ts +++ b/domain/types/index.ts @@ -1,20 +1,12 @@ -import { Requirement } from "../requirements/Requirement.js"; +import type { Collection } from "@mikro-orm/core"; export type Constructor = (new (...args: any[]) => T) | (abstract new (...args: any[]) => T); /** * A type that represents all the members of a type T that are not functions + * and are not collections */ export type Properties = Pick any ? never : K -}[keyof T]>; - -/** - * Represents a requirement model with relations - */ -// TODO: move to a base DTO object -// possibly related to the following work: https://github.com/final-hill/cathedral/issues/164#issuecomment-2381004280 -export type ReqRelModel = R & { - parentComponent?: string, - solutionId: string -} \ No newline at end of file + [K in keyof T]: T[K] extends (...args: any[]) => any ? never : + T[K] extends Collection ? never : K +}[keyof T]>; \ No newline at end of file diff --git a/migrations/.snapshot-cathedral.json b/migrations/.snapshot-cathedral.json index b9c9a78d..319401dd 100644 --- a/migrations/.snapshot-cathedral.json +++ b/migrations/.snapshot-cathedral.json @@ -684,22 +684,13 @@ }, { "columns": { - "id": { - "name": "id", - "type": "uuid", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "uuid" - }, "left_id": { "name": "left_id", "type": "uuid", "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, + "nullable": false, "mappedType": "uuid" }, "right_id": { @@ -708,7 +699,7 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": true, + "nullable": false, "mappedType": "uuid" }, "rel_type": { @@ -752,9 +743,10 @@ { "keyName": "requirement_relation_pkey", "columnNames": [ - "id" + "left_id", + "right_id" ], - "composite": false, + "composite": true, "constraint": true, "primary": true, "unique": true @@ -772,7 +764,7 @@ "id" ], "referencedTableName": "public.requirement", - "deleteRule": "cascade" + "updateRule": "cascade" }, "requirement_relation_right_id_foreign": { "constraintName": "requirement_relation_right_id_foreign", @@ -784,7 +776,7 @@ "id" ], "referencedTableName": "public.requirement", - "deleteRule": "cascade" + "updateRule": "cascade" } }, "nativeEnums": {} diff --git a/migrations/Migration20241129150626.ts b/migrations/Migration20241129150626.ts new file mode 100644 index 00000000..39783512 --- /dev/null +++ b/migrations/Migration20241129150626.ts @@ -0,0 +1,41 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20241129150626 extends Migration { + + override async up(): Promise { + this.addSql(`alter table "requirement_relation" drop constraint "requirement_relation_left_id_foreign";`); + this.addSql(`alter table "requirement_relation" drop constraint "requirement_relation_right_id_foreign";`); + + this.addSql(`alter table "requirement_relation" drop constraint "requirement_relation_pkey";`); + this.addSql(`alter table "requirement_relation" drop column "id";`); + + this.addSql(`alter table "requirement_relation" alter column "left_id" drop default;`); + this.addSql(`alter table "requirement_relation" alter column "left_id" type uuid using ("left_id"::text::uuid);`); + this.addSql(`alter table "requirement_relation" alter column "left_id" set not null;`); + this.addSql(`alter table "requirement_relation" alter column "right_id" drop default;`); + this.addSql(`alter table "requirement_relation" alter column "right_id" type uuid using ("right_id"::text::uuid);`); + this.addSql(`alter table "requirement_relation" alter column "right_id" set not null;`); + this.addSql(`alter table "requirement_relation" add constraint "requirement_relation_left_id_foreign" foreign key ("left_id") references "requirement" ("id") on update cascade;`); + this.addSql(`alter table "requirement_relation" add constraint "requirement_relation_right_id_foreign" foreign key ("right_id") references "requirement" ("id") on update cascade;`); + this.addSql(`alter table "requirement_relation" add constraint "requirement_relation_pkey" primary key ("left_id", "right_id");`); + } + + override async down(): Promise { + this.addSql(`alter table "requirement_relation" drop constraint "requirement_relation_left_id_foreign";`); + this.addSql(`alter table "requirement_relation" drop constraint "requirement_relation_right_id_foreign";`); + + this.addSql(`alter table "requirement_relation" drop constraint "requirement_relation_pkey";`); + + this.addSql(`alter table "requirement_relation" add column "id" uuid not null;`); + this.addSql(`alter table "requirement_relation" alter column "left_id" drop default;`); + this.addSql(`alter table "requirement_relation" alter column "left_id" type uuid using ("left_id"::text::uuid);`); + this.addSql(`alter table "requirement_relation" alter column "left_id" drop not null;`); + this.addSql(`alter table "requirement_relation" alter column "right_id" drop default;`); + this.addSql(`alter table "requirement_relation" alter column "right_id" type uuid using ("right_id"::text::uuid);`); + this.addSql(`alter table "requirement_relation" alter column "right_id" drop not null;`); + this.addSql(`alter table "requirement_relation" add constraint "requirement_relation_left_id_foreign" foreign key ("left_id") references "requirement" ("id") on delete cascade;`); + this.addSql(`alter table "requirement_relation" add constraint "requirement_relation_right_id_foreign" foreign key ("right_id") references "requirement" ("id") on delete cascade;`); + this.addSql(`alter table "requirement_relation" add constraint "requirement_relation_pkey" primary key ("id");`); + } + +} diff --git a/server/api/parse-requirement/index.get.ts b/server/api/parse-requirement/index.get.ts index f1f7692b..a71e4290 100644 --- a/server/api/parse-requirement/index.get.ts +++ b/server/api/parse-requirement/index.get.ts @@ -1,24 +1,14 @@ import { z } from "zod"; -import { fork } from "~/server/data/orm.js" -import { getServerSession } from '#auth' -import { OrganizationInteractor } from "~/application"; - -const querySchema = z.object({ - solutionId: z.string().uuid(), - organizationSlug: z.string() -}) +import { ParsedRequirement } from "~/domain/requirements"; /** - * Return all ParsedRequirements for a Solution. + * Returns all ParsedRequirements that match the query parameters */ -export default defineEventHandler(async (event) => { - const { organizationSlug, solutionId } = await validateEventQuery(event, querySchema), - session = (await getServerSession(event))!, - organizationInteractor = new OrganizationInteractor({ - userId: session.id, - organizationSlug, - entityManager: fork() - }) - - return organizationInteractor.getParsedRequirements(solutionId) +export default findRequirementsHttpHandler({ + ReqClass: ParsedRequirement, + querySchema: z.object({ + name: z.string().optional(), + description: z.string().optional(), + isSilence: z.boolean().optional().default(false) + }) }) \ No newline at end of file