From b1f2d6fb4804b48c3e9a6a14ea7b68dd011ee2a9 Mon Sep 17 00:00:00 2001 From: Michael Haufe Date: Thu, 10 Oct 2024 11:31:48 -0500 Subject: [PATCH] Created history UI and DB auditing (#391) --- components/XDataTable.vue | 81 ++++++++- migrations/.snapshot-cathedral.json | 161 +++++++++++------- migrations/Migration20241009234603.ts | 16 ++ .../environment/assumptions.client.vue | 3 +- .../environment/components.client.vue | 3 +- .../environment/constraints.client.vue | 2 +- .../environment/effects.client.vue | 3 +- .../environment/glossary.client.vue | 3 +- .../environment/invariants.client.vue | 3 +- .../goals/functionality.client.vue | 3 +- .../goals/limitations.client.vue | 3 +- .../goals/obstacles.client.vue | 3 +- .../[solution-slug]/goals/outcomes.client.vue | 3 +- .../goals/scenarios.client.vue | 3 +- .../goals/stakeholders.client.vue | 2 +- .../project/roles-personnel.client.vue | 3 +- .../system/components.client.vue | 2 +- .../system/functionality.client.vue | 2 +- .../system/scenarios.client.vue | 6 +- pages/o/[organization-slug]/users.vue | 7 +- server/api/audit-log/index.get.ts | 34 ++++ server/data/subscribers/AuditSubscriber.ts | 2 + server/domain/AuditLog.ts | 29 +++- 23 files changed, 287 insertions(+), 90 deletions(-) create mode 100644 migrations/Migration20241009234603.ts create mode 100644 server/api/audit-log/index.get.ts diff --git a/components/XDataTable.vue b/components/XDataTable.vue index 02e55c91..b7074fc1 100644 --- a/components/XDataTable.vue +++ b/components/XDataTable.vue @@ -3,6 +3,7 @@ import type Dialog from 'primevue/dialog' import type DataTable from 'primevue/datatable' import { FilterMatchMode } from 'primevue/api'; import camelCaseToTitle from '~/utils/camelCaseToTitle.js'; +import { AuditLog } from '~/server/domain'; export type ViewFieldType = 'text' | 'textarea' | 'number' | 'date' | 'boolean' | 'hidden' | 'object' @@ -15,6 +16,8 @@ const props = defineProps<{ createModel: { [K in keyof RowType]?: FormFieldType }, editModel: { [K in keyof RowType]?: FormFieldType }, loading: boolean, + showHistory: boolean, + organizationSlug: string, onCreate: (data: RowType) => Promise, onDelete: (id: string) => Promise, onUpdate: (data: RowType) => Promise @@ -35,7 +38,12 @@ const dataTable = ref(), createDialogItem = ref(Object.create(null)), editDialog = ref(), editDialogVisible = ref(false), - editDialogItem = ref(Object.create(null)) + editDialogItem = ref(Object.create(null)), + historyDialog = ref(), + historyDialogVisible = ref(false), + historyItems = ref<{ date: string, entity: Record }[]>([]), + selectedHistoryItem = ref<{ date: string, entity: Record }>({ date: '', entity: {} }), + historyDialogLoading = ref(false) const filters = ref>({ 'global': { value: null, matchMode: FilterMatchMode.CONTAINS } @@ -49,6 +57,34 @@ const openEditDialog = (item: RowType) => { editDialogItem.value = { ...item } } +const openHistoryDialog = async (item: RowType) => { + historyDialogLoading.value = true + historyDialogVisible.value = true + const auditLog = await $fetch(`/api/audit-log`, { + method: 'GET', + query: { + entityId: item.id, + organizationSlug: props.organizationSlug + } + }) + + historyItems.value = [ + { date: 'Current', entity: item }, + ...auditLog.map(log => ({ + date: new Date(log.createdAt).toLocaleString(), + entity: JSON.parse(log.entity) + })) + ] + + selectedHistoryItem.value = historyItems.value[0] + historyDialogLoading.value = false +} + +const onHistoryChange = (e: Event) => { + const select = e.target as HTMLSelectElement + selectedHistoryItem.value = historyItems.value[select.selectedIndex] +} + const onDelete = (item: RowType) => new Promise((resolve, _reject) => { confirm.require({ message: `Are you sure you want to delete ${item.name}?`, @@ -151,8 +187,10 @@ const onEditDialogCancel = () => { @@ -262,4 +300,39 @@ const onEditDialogCancel = () => { - \ No newline at end of file + + + +
+
+ + + {{ selectedHistoryItem.entity[key].toLocaleString() }} + + + {{ selectedHistoryItem.entity[key]?.name ?? JSON.stringify(selectedHistoryItem.entity[key]) }} + + {{ selectedHistoryItem.entity[key] }} +
+
+ +
+ + + \ No newline at end of file diff --git a/migrations/.snapshot-cathedral.json b/migrations/.snapshot-cathedral.json index a87fd77a..5e0a05c1 100644 --- a/migrations/.snapshot-cathedral.json +++ b/migrations/.snapshot-cathedral.json @@ -83,71 +83,6 @@ "foreignKeys": {}, "nativeEnums": {} }, - { - "columns": { - "id": { - "name": "id", - "type": "uuid", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "uuid" - }, - "type": { - "name": "type", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "enumItems": [ - "create", - "update", - "delete", - "update_early", - "delete_early" - ], - "mappedType": "enum" - }, - "entity": { - "name": "entity", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "json" - }, - "created_at": { - "name": "created_at", - "type": "timestamptz", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 6, - "mappedType": "datetime" - } - }, - "name": "audit_log", - "schema": "public", - "indexes": [ - { - "keyName": "audit_log_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": {}, - "nativeEnums": {} - }, { "columns": { "id": { @@ -2937,6 +2872,102 @@ }, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "solution_id": { + "name": "solution_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "type": { + "name": "type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "create", + "update", + "delete", + "update_early", + "delete_early" + ], + "mappedType": "enum" + }, + "entity": { + "name": "entity", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "audit_log", + "schema": "public", + "indexes": [ + { + "keyName": "audit_log_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "audit_log_solution_id_foreign": { + "constraintName": "audit_log_solution_id_foreign", + "columnNames": [ + "solution_id" + ], + "localTableName": "public.audit_log", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.solution", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { diff --git a/migrations/Migration20241009234603.ts b/migrations/Migration20241009234603.ts new file mode 100644 index 00000000..3b893392 --- /dev/null +++ b/migrations/Migration20241009234603.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20241009234603 extends Migration { + + override async up(): Promise { + this.addSql(`alter table "audit_log" add column "solution_id" uuid not null, add column "entity_id" uuid not null;`); + this.addSql(`alter table "audit_log" add constraint "audit_log_solution_id_foreign" foreign key ("solution_id") references "solution" ("id") on update cascade;`); + } + + override async down(): Promise { + this.addSql(`alter table "audit_log" drop constraint "audit_log_solution_id_foreign";`); + + this.addSql(`alter table "audit_log" drop column "solution_id", drop column "entity_id";`); + } + +} diff --git a/pages/o/[organization-slug]/[solution-slug]/environment/assumptions.client.vue b/pages/o/[organization-slug]/[solution-slug]/environment/assumptions.client.vue index d9d26c95..7b613bc3 100644 --- a/pages/o/[organization-slug]/[solution-slug]/environment/assumptions.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/environment/assumptions.client.vue @@ -75,6 +75,7 @@ const onUpdate = async (data: Assumption) => {

+ :on-delete="onDelete" :on-update="onUpdate" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/environment/components.client.vue b/pages/o/[organization-slug]/[solution-slug]/environment/components.client.vue index c8385ba1..5989c323 100644 --- a/pages/o/[organization-slug]/[solution-slug]/environment/components.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/environment/components.client.vue @@ -68,6 +68,7 @@ const onUpdate = async (data: EnvironmentComponent) => {

+ :on-create="onCreate" :on-delete="onDelete" :on-update="onUpdate" :loading="status === 'pending'" + :show-history="true" :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/environment/constraints.client.vue b/pages/o/[organization-slug]/[solution-slug]/environment/constraints.client.vue index 29ed8bdc..914520d7 100644 --- a/pages/o/[organization-slug]/[solution-slug]/environment/constraints.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/environment/constraints.client.vue @@ -75,6 +75,6 @@ const onUpdate = async (data: Constraint) => { :createModel="{ name: 'text', category: Object.values(ConstraintCategory), statement: 'text' }" :editModel="{ id: 'hidden', name: 'text', category: Object.values(ConstraintCategory), statement: 'text' }" :datasource="constraints" :on-create="onCreate" :on-delete="onDelete" :on-update="onUpdate" - :loading="status === 'pending'"> + :loading="status === 'pending'" :show-history="true" :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/environment/effects.client.vue b/pages/o/[organization-slug]/[solution-slug]/environment/effects.client.vue index 8778f849..aecc3889 100644 --- a/pages/o/[organization-slug]/[solution-slug]/environment/effects.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/environment/effects.client.vue @@ -70,6 +70,7 @@ const onDelete = async (id: string) => {

+ :on-delete="onDelete" :on-update="onUpdate" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/environment/glossary.client.vue b/pages/o/[organization-slug]/[solution-slug]/environment/glossary.client.vue index 8ed04911..d81fb0e1 100644 --- a/pages/o/[organization-slug]/[solution-slug]/environment/glossary.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/environment/glossary.client.vue @@ -70,6 +70,7 @@ const onDelete = async (id: string) => {

+ :on-delete="onDelete" :on-update="onUpdate" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/environment/invariants.client.vue b/pages/o/[organization-slug]/[solution-slug]/environment/invariants.client.vue index a98d2405..950e3d7a 100644 --- a/pages/o/[organization-slug]/[solution-slug]/environment/invariants.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/environment/invariants.client.vue @@ -72,6 +72,7 @@ const onDelete = async (id: string) => { + :on-update="onUpdate" :on-delete="onDelete" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/goals/functionality.client.vue b/pages/o/[organization-slug]/[solution-slug]/goals/functionality.client.vue index dd07e027..fade1150 100644 --- a/pages/o/[organization-slug]/[solution-slug]/goals/functionality.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/goals/functionality.client.vue @@ -74,6 +74,7 @@ const onDelete = async (id: string) => { + :on-create="onCreate" :on-update="onUpdate" :on-delete="onDelete" :loading="status === 'pending'" + :show-history="true" :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/goals/limitations.client.vue b/pages/o/[organization-slug]/[solution-slug]/goals/limitations.client.vue index c5c6a1c8..e437091c 100644 --- a/pages/o/[organization-slug]/[solution-slug]/goals/limitations.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/goals/limitations.client.vue @@ -71,6 +71,7 @@ const onDelete = async (id: string) => { + :on-update="onUpdate" :on-delete="onDelete" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/goals/obstacles.client.vue b/pages/o/[organization-slug]/[solution-slug]/goals/obstacles.client.vue index 4fd61af6..085bf4a5 100644 --- a/pages/o/[organization-slug]/[solution-slug]/goals/obstacles.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/goals/obstacles.client.vue @@ -68,6 +68,7 @@ const onDelete = async (id: string) => { + :on-update="onUpdate" :on-delete="onDelete" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/goals/outcomes.client.vue b/pages/o/[organization-slug]/[solution-slug]/goals/outcomes.client.vue index b94c2510..db478df6 100644 --- a/pages/o/[organization-slug]/[solution-slug]/goals/outcomes.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/goals/outcomes.client.vue @@ -72,6 +72,7 @@ const onDelete = async (id: string) => { + :onUpdate="onUpdate" :onDelete="onDelete" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/goals/scenarios.client.vue b/pages/o/[organization-slug]/[solution-slug]/goals/scenarios.client.vue index 6c11ee4b..f534ee24 100644 --- a/pages/o/[organization-slug]/[solution-slug]/goals/scenarios.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/goals/scenarios.client.vue @@ -119,6 +119,7 @@ const onUserStoryDelete = async (id: string) => { functionalBehavior: { type: 'requirement', options: functionalBehaviors ?? [] }, outcome: { type: 'requirement', options: outcomes ?? [] } }" :datasource="userStories" :onCreate="onUserStoryCreate" :onUpdate="onUserStoryUpdate" - :onDelete="onUserStoryDelete" :loading="status === 'pending'"> + :onDelete="onUserStoryDelete" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/goals/stakeholders.client.vue b/pages/o/[organization-slug]/[solution-slug]/goals/stakeholders.client.vue index 4333224b..bf17aae2 100644 --- a/pages/o/[organization-slug]/[solution-slug]/goals/stakeholders.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/goals/stakeholders.client.vue @@ -139,7 +139,7 @@ const onDelete = async (id: string) => { category: Object.values(StakeholderCategory), segmentation: Object.values(StakeholderSegmentation) }" :datasource="stakeholders" :on-create="onCreate" :on-update="onUpdate" :on-delete="onDelete" - :loading="status === 'pending'"> + :loading="status === 'pending'" :show-history="true" :organizationSlug="organizationslug"> diff --git a/pages/o/[organization-slug]/[solution-slug]/project/roles-personnel.client.vue b/pages/o/[organization-slug]/[solution-slug]/project/roles-personnel.client.vue index f768e31f..3d5bdbde 100644 --- a/pages/o/[organization-slug]/[solution-slug]/project/roles-personnel.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/project/roles-personnel.client.vue @@ -72,6 +72,7 @@ const onDelete = async (id: string) => { + :on-update="onUpdate" :on-delete="onDelete" :loading="status === 'pending'" :show-history="true" + :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/system/components.client.vue b/pages/o/[organization-slug]/[solution-slug]/system/components.client.vue index f3130e42..1cb2efc3 100644 --- a/pages/o/[organization-slug]/[solution-slug]/system/components.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/system/components.client.vue @@ -83,6 +83,6 @@ const onDelete = async (id: string) => { statement: 'text', parentComponent: { type: 'requirement', options: systemComponents ?? [] } }" :datasource="systemComponents" :onCreate="onCreate" :onUpdate="onUpdate" :onDelete="onDelete" - :loading="status === 'pending'"> + :loading="status === 'pending'" :show-history="true" :organizationSlug="organizationslug"> \ No newline at end of file diff --git a/pages/o/[organization-slug]/[solution-slug]/system/functionality.client.vue b/pages/o/[organization-slug]/[solution-slug]/system/functionality.client.vue index 182bf0ee..8d3949c1 100644 --- a/pages/o/[organization-slug]/[solution-slug]/system/functionality.client.vue +++ b/pages/o/[organization-slug]/[solution-slug]/system/functionality.client.vue @@ -105,7 +105,7 @@ const componentSortField = ref('name')
 { This section is disabled temporarily. }