Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

435 implement temporal versioning #470

Draft
wants to merge 11 commits into
base: master
Choose a base branch
from
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
448 changes: 98 additions & 350 deletions application/OrganizationInteractor.ts

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions components/XDataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] }

Expand Down Expand Up @@ -186,7 +186,7 @@ const onEditDialogCancel = () => {
:globalFilterFields="Object.keys(props.datasource?.[0] ?? {})" :loading="props.loading" stripedRows>
<Column
v-for="key of Object.keys(props.viewModel).filter(k => props.viewModel[k as keyof RowType] !== 'hidden')"
:key="key" :field="key" :header="camelCaseToTitle(key)" sortable>
:key="key" :field="key" :header="camelCaseToTitleCase(key)" sortable>
<template #body="{ data, field }">
<span v-if="props.viewModel[field as keyof RowType] === 'text'">{{ data[field] }}</span>
<span v-else-if="
Expand Down Expand Up @@ -229,7 +229,7 @@ const onEditDialogCancel = () => {
<div class="field grid"
v-for="key of Object.keys(props.createModel).filter(k => props.createModel[k as keyof RowType] !== 'hidden')"
:key="key" :field="key">
<label :for="key" class="col-4">{{ camelCaseToTitle(key) }}</label>
<label :for="key" class="col-4">{{ camelCaseToTitleCase(key) }}</label>

<InputText v-if="props.createModel[key as keyof RowType] === 'text'" :name="key"
v-model.trim="createDialogItem[key]" class="col-8" />
Expand All @@ -256,7 +256,7 @@ const onEditDialogCancel = () => {
(props.createModel[key as keyof RowType] as any).type === 'requirement'
" :name="key" class="p-inputtext p-component col-8"
v-model.trim="(createDialogItem[key] ?? { id: '' }).id">
<option value="" disabled>Select a {{ camelCaseToTitle(key) }}</option>
<option value="" disabled>Select a {{ camelCaseToTitleCase(key) }}</option>
<option v-for="option of (props.createModel[key as keyof RowType] as any).options" :key="option?.id"
:value="option?.id">
{{ option.name }}
Expand All @@ -279,7 +279,7 @@ const onEditDialogCancel = () => {
<form id="editDialogForm" autocomplete="off" @submit.prevent="onEditDialogSave" @reset="onEditDialogCancel">
<div class="field grid" v-for="key of Object.keys(props.editModel)" :key="key" :field="key">
<label v-if="props.editModel[key as keyof RowType] !== 'hidden'" :for="key" class="col-4">{{
camelCaseToTitle(key) }}</label>
camelCaseToTitleCase(key) }}</label>

<InputText v-if="props.editModel[key as keyof RowType] === 'text'" :name="key"
v-model.trim="editDialogItem[key]" class="col-8" />
Expand Down Expand Up @@ -308,7 +308,7 @@ const onEditDialogCancel = () => {
(props.editModel[key as keyof RowType] as any).type === 'requirement'
" :name="key" class="p-inputtext p-component col-8"
v-model.trim="(editDialogItem[key] ?? { id: '' }).id">
<option value="" disabled>Select a {{ camelCaseToTitle(key) }}</option>
<option value="" disabled>Select a {{ camelCaseToTitleCase(key) }}</option>
<option v-for="option of (props.editModel[key as keyof RowType] as any).options" :key="option.id"
:value="option.id">
{{ option.name }}
Expand All @@ -331,7 +331,7 @@ const onEditDialogCancel = () => {
<DataTable ref="recycleBin" :value="recycleItems" dataKey="date" :loading="recycleDialogLoading">
<Column field="date" header="Date" sortable />
<Column v-for="key of Object.keys(recycleItems?.[0]?.entity ?? {})" :key="key" :field="key"
:header="camelCaseToTitle(key)">
:header="camelCaseToTitleCase(key)">
<template #body="{ data, field }">
<span v-if="data.entity[field] instanceof Date">{{ data.entity[field].toLocaleString() }}</span>
<span v-else-if="typeof data.entity[field] === 'object'">
Expand Down Expand Up @@ -363,7 +363,7 @@ const onEditDialogCancel = () => {
</template>
<section>
<div class="field grid" v-for="key of Object.keys(selectedHistoryItem.entity)" :key="key">
<label :for="key" class="col-4">{{ camelCaseToTitle(key) }}:</label>
<label :for="key" class="col-4">{{ camelCaseToTitleCase(key) }}:</label>
<span class="col-8" v-if="selectedHistoryItem.entity[key] instanceof Date">
{{ selectedHistoryItem.entity[key].toLocaleString() }}
</span>
Expand Down
31 changes: 31 additions & 0 deletions domain/AuditMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Represents the metadata of an entity that is used to track the creation and modification of the entity
*/
export abstract class AuditMetadata {
constructor(props: Pick<AuditMetadata, keyof AuditMetadata>) {
this.createdById = props.createdById;
this.creationDate = props.creationDate;
this.lastModified = props.lastModified;
this.modifiedById = props.modifiedById;
}

/**
* The user who created the entity
*/
readonly createdById: string;

/**
* The date and time when the entity was created
*/
readonly creationDate: Date

/**
* The date and time when the entity was last modified
*/
readonly lastModified: Date;

/**
* The user who last modified the entity
*/
readonly modifiedById: string;
}
58 changes: 28 additions & 30 deletions domain/application/AppUser.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,63 @@
import { BaseEntity, Entity, Enum, Property } from "@mikro-orm/core";
import { AppRole } from "./AppRole.js";
import { type Properties } from "../types/index.js";

/**
* An AppUser is a user of the application
*/
@Entity()
export class AppUser extends BaseEntity {
constructor(props: Properties<AppUser>) {
super()
this.id = props.id;
this.creationDate = props.creationDate;
this.isSystemAdmin = props.isSystemAdmin;
this.lastLoginDate = props.lastLoginDate;
this.name = props.name;
this.email = props.email;
this.role = props.role;
export class AppUser {
constructor(props: Pick<AppUser, keyof AppUser>) {
Object.assign(this, props);

// email address: https://stackoverflow.com/a/574698
if (props.name.length > 254)
throw new Error('Name too long');
if (!props.email.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/))
throw new Error('Invalid email address');
if (props.email.length > 254)
throw new Error('Email address too long');
}

/**
* The unique identifier of the AppUser (uuid)
*/
@Property({ type: 'uuid', primary: true })
id: string;
readonly id!: string;

/**
* The name of the AppUser
*/
@Property({ type: 'string', length: 254 })
name: string;
readonly name!: string;

/**
* The date and time when the user was last modified
*/
readonly effectiveFrom!: Date;

/**
* Whether the user is deleted
*/
readonly isDeleted!: boolean;

/**
* The email address of the AppUser
*/
// email address: https://stackoverflow.com/a/574698
@Property({ type: 'string', length: 254 })
email: string;
readonly email!: string;

/**
* The date the AppUser was created
*/
@Property({ type: 'datetime' })
creationDate: Date;
readonly creationDate!: Date;

/**
* The date the AppUser last logged in
*/
@Property({ type: 'datetime', nullable: true })
lastLoginDate?: Date;
readonly lastLoginDate?: Date;

/**
* Whether the AppUser is a system administrator
*/
@Property({ type: 'boolean' })
isSystemAdmin: boolean;
readonly isSystemAdmin!: boolean;

/**
* The role of the AppUser.
*/
// FIXME: this field is not mapped in the ORM. It is populated in the API layer.
// It's a design smell that needs to be addressed.
@Enum({ items: () => AppRole, persist: false })
role?: AppRole;
readonly role!: AppRole;
}
33 changes: 16 additions & 17 deletions domain/application/AppUserOrganizationRole.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
import { BaseEntity, Entity, Enum, ManyToOne } from "@mikro-orm/core";
import { AppRole } from "./AppRole.js";
import { AppUser } from "./AppUser.js";
import { type Properties } from "../types/index.js";
import { Organization } from "../requirements/Organization.js";

/**
* An AppUserOrganizationRole is a mapping between an AppUser, an Organization, and a Role
*/
@Entity()
export class AppUserOrganizationRole extends BaseEntity {
constructor(props: Properties<AppUserOrganizationRole>) {
super()
this.appUser = props.appUser;
this.organization = props.organization;
this.role = props.role;
export class AppUserOrganizationRole {
constructor(props: Pick<AppUserOrganizationRole, keyof AppUserOrganizationRole>) {
Object.assign(this, props);
}

/**
* The user associated with the OrganizationRole
*/
@ManyToOne({ primary: true, entity: () => AppUser })
appUser: AppUser;
readonly appUserId!: string;

/**
* The Organization associated with the OrganizationRole
*/
@ManyToOne({ primary: true, entity: () => Organization })
organization: Organization;
readonly organizationId!: string;

/**
* The Role associated with the OrganizationRole
*/
@Enum({ items: () => AppRole, primary: true })
role: AppRole
readonly role!: AppRole

/**
* Whether the relation is deleted
*/
readonly isDeleted!: boolean;

/**
* The date and time when the relation was last modified
*/
readonly effectiveFrom!: Date;
}
55 changes: 0 additions & 55 deletions domain/application/AuditLog.ts

This file was deleted.

3 changes: 1 addition & 2 deletions domain/application/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export * from './AppRole.js';
export * from './AppUser.js';
export * from './AppUserOrganizationRole.js';
export * from './AuditLog.js';
export * from './AppUserOrganizationRole.js';
2 changes: 0 additions & 2 deletions domain/relations/Belongs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Entity } from "@mikro-orm/core";
import { RequirementRelation } from "./RequirementRelation.js";

/**
* X ⊆ Y
*
* X is a sub-requirement of Y; textually included
*/
@Entity()
export class Belongs extends RequirementRelation { }
6 changes: 1 addition & 5 deletions domain/relations/Characterizes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { Entity } from "@mikro-orm/core";
import { RequirementRelation } from "./RequirementRelation.js";
import { MetaRequirement } from "../requirements/MetaRequirement.js";
import { type Properties } from "../types/index.js";

/**
* X → Y
*
* Meta-requirement X applies to requirement Y
*/
@Entity()
export class Characterizes extends RequirementRelation {
constructor(props: Properties<Omit<Characterizes, 'id' | 'left'> & { left: MetaRequirement }>) {
constructor(props: Omit<Pick<Characterizes, keyof Characterizes>, 'id' | 'left'> & { left: MetaRequirement }) {
super(props);
this.left = props.left;
}
}
2 changes: 0 additions & 2 deletions domain/relations/Constrains.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Entity } from "@mikro-orm/core";
import { RequirementRelation } from "./RequirementRelation.js";

/**
* X ▸ Y
*
* Constraint X applies to Y
*/
@Entity()
export class Constrains extends RequirementRelation { }
2 changes: 0 additions & 2 deletions domain/relations/Contradicts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Entity } from "@mikro-orm/core";
import { RequirementRelation } from "./RequirementRelation.js";

/**
* X ⊕ Y
*
* Properties specified by X and Y cannot both hold
*/
@Entity()
export class Contradicts extends RequirementRelation { }
2 changes: 0 additions & 2 deletions domain/relations/Details.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Entity } from "@mikro-orm/core";
import { Extends } from "./Extends.js";

/**
* X » Y
*
* X adds detail to properties of Y
*/
@Entity()
export class Details extends Extends { }
Loading
Loading