diff --git a/apps/zeity/server/api/times/[id].delete.ts b/apps/zeity/server/api/times/[id].delete.ts index 1a9b87d2..1509b537 100644 --- a/apps/zeity/server/api/times/[id].delete.ts +++ b/apps/zeity/server/api/times/[id].delete.ts @@ -2,9 +2,11 @@ import { z } from 'zod'; import { eq } from '@zeity/database'; import { times } from '@zeity/database/time'; +import { AUDIT_ENTITY_TIME, AUDIT_ACTION_DELETE } from '@zeity/types'; import { findTimeById } from '~~/server/utils/time'; +import { logAuditEvent } from '~~/server/utils/audit-log'; -export default defineEventHandler(async (event) => { +export default defineEventHandler(async event => { const session = await requireUserSession(event); const organisation = await requireOrganisationSession(event); @@ -33,7 +35,7 @@ export default defineEventHandler(async (event) => { const organisationMemberId = await getOrganisationMemberByUserId( organisation.value, session.user.id, - ).then((member) => member?.id); + ).then(member => member?.id); if ( existing.organisationId !== organisation.value || @@ -45,10 +47,23 @@ export default defineEventHandler(async (event) => { }); } - await useDrizzle() - .delete(times) - .where(eq(times.id, params.data.id)) - .returning(); + const db = useDrizzle(); + await db.transaction(async tx => { + await logAuditEvent({ + tx, + event: { + entityType: AUDIT_ENTITY_TIME, + action: AUDIT_ACTION_DELETE, + entityId: params.data.id, + oldValues: existing, + newValues: null, + organisationId: organisation.value, + organisationMemberId: organisationMemberId!, + }, + }); + + await tx.delete(times).where(eq(times.id, params.data.id)); + }); return sendNoContent(event); }); diff --git a/apps/zeity/server/api/times/[id].patch.ts b/apps/zeity/server/api/times/[id].patch.ts index 7d57e1e9..08a52a7d 100644 --- a/apps/zeity/server/api/times/[id].patch.ts +++ b/apps/zeity/server/api/times/[id].patch.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import { eq } from '@zeity/database'; import { times } from '@zeity/database/time'; -import { TIME_TYPES } from '@zeity/types'; +import { TIME_TYPES, AUDIT_ENTITY_TIME, AUDIT_ACTION_UPDATE } from '@zeity/types'; import { findTimeById } from '~~/server/utils/time'; import { doesProjectsBelongsToOrganisation } from '~~/server/utils/project'; -export default defineEventHandler(async (event) => { +export default defineEventHandler(async event => { const session = await requireUserSession(event); const organisation = await requireOrganisationSession(event); @@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => { const organisationMemberId = await getOrganisationMemberByUserId( organisation.value, session.user.id, - ).then((member) => member?.id); + ).then(member => member?.id); if ( existing.organisationId !== organisation.value || @@ -82,12 +82,25 @@ export default defineEventHandler(async (event) => { }); } - const result = await useDrizzle() - .update(times) - .set(body.data) - .where(eq(times.id, params.data.id)) - .returning() - .then((res) => res[0]); + return await useDrizzle().transaction(async tx => { + const result = await tx + .update(times) + .set(body.data) + .where(eq(times.id, params.data.id)) + .returning() + .then(res => res[0]); - return result; + await logAuditEvent({ + tx, + event: { + entityType: AUDIT_ENTITY_TIME, + action: AUDIT_ACTION_UPDATE, + entityId: params.data.id, + oldValues: existing, + newValues: result, + organisationId: organisation.value, + organisationMemberId: organisationMemberId!, + }, + }); + }); }); diff --git a/apps/zeity/server/api/times/index.post.ts b/apps/zeity/server/api/times/index.post.ts index 6bf4009a..14f3ac3c 100644 --- a/apps/zeity/server/api/times/index.post.ts +++ b/apps/zeity/server/api/times/index.post.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; import { times } from '@zeity/database/time'; -import { TIME_TYPES, TIME_TYPE_MANUAL } from '@zeity/types'; +import { TIME_TYPES, TIME_TYPE_MANUAL } from '@zeity/types/time'; +import { AUDIT_ENTITY_TIME, AUDIT_ACTION_CREATE } from '@zeity/types/audit-log'; import { doesProjectsBelongsToOrganisation } from '~~/server/utils/project'; import { doesTasksBelongToOrganisation } from '~~/server/utils/task'; @@ -68,23 +69,38 @@ export default defineEventHandler(async event => { }); } - const result = await useDrizzle() - .insert(times) - .values({ - ...body.data, - organisationId: organisation.value, - organisationMemberId, - }) - .returning() - .then(data => data[0]); + return await useDrizzle().transaction(async tx => { + const result = await tx + .insert(times) + .values({ + ...body.data, + organisationId: organisation.value, + organisationMemberId, + }) + .returning() + .then(data => data[0]); - if (!result) { - console.error('Failed to create time', result); - throw createError({ - statusCode: 500, - message: 'Failed to create time', + if (!result) { + console.error('Failed to create time', result); + throw createError({ + statusCode: 500, + message: 'Failed to create time', + }); + } + + await logAuditEvent({ + tx, + event: { + entityType: AUDIT_ENTITY_TIME, + action: AUDIT_ACTION_CREATE, + entityId: result.id, + oldValues: null, + newValues: result as unknown as Record, + organisationId: organisation.value, + organisationMemberId, + }, }); - } - return result; + return result; + }); }); diff --git a/apps/zeity/server/api/times/sync.post.ts b/apps/zeity/server/api/times/sync.post.ts index 7b92a4e3..8ad6e1f0 100644 --- a/apps/zeity/server/api/times/sync.post.ts +++ b/apps/zeity/server/api/times/sync.post.ts @@ -1,7 +1,7 @@ import z from 'zod'; import { times } from '@zeity/database/time'; -import { TIME_TYPES } from '@zeity/types'; +import { TIME_TYPES, AUDIT_ENTITY_TIME, AUDIT_ACTION_CREATE } from '@zeity/types'; import { doesTasksBelongToOrganisation } from '~~/server/utils/task'; export default defineEventHandler(async event => { @@ -70,16 +70,33 @@ export default defineEventHandler(async event => { }); } - const result = await useDrizzle() - .insert(times) - .values( - body.data.map(time => ({ - ...time, - organisationId: organisation.value, - organisationMemberId, - })), - ) - .returning(); + return await useDrizzle().transaction(async tx => { + const result = await tx + .insert(times) + .values( + body.data.map(time => ({ + ...time, + organisationId: organisation.value, + organisationMemberId, + })), + ) + .returning(); - return result; + if (result.length > 0) { + await logAuditEvents({ + tx, + events: result.map(entry => ({ + entityType: AUDIT_ENTITY_TIME, + entityId: entry.id, + action: AUDIT_ACTION_CREATE, + oldValues: null, + newValues: entry, + organisationId: organisation.value, + organisationMemberId, + })), + }); + } + + return result; + }); }); diff --git a/apps/zeity/server/utils/audit-log.ts b/apps/zeity/server/utils/audit-log.ts new file mode 100644 index 00000000..1c992a83 --- /dev/null +++ b/apps/zeity/server/utils/audit-log.ts @@ -0,0 +1,11 @@ +import { auditLogs, type NewAuditLogRecord } from '@zeity/database/audit-log'; + +export function logAuditEvent({ tx, event }: { tx?: unknown; event: NewAuditLogRecord }) { + const db = (tx ?? useDrizzle()) as ReturnType; + return db.insert(auditLogs).values(event); +} + +export function logAuditEvents({ tx, events }: { tx?: unknown; events: NewAuditLogRecord[] }) { + const db = (tx ?? useDrizzle()) as ReturnType; + return db.insert(auditLogs).values(events); +} diff --git a/libs/database/migrations/0008_strong_green_goblin.sql b/libs/database/migrations/0008_strong_green_goblin.sql new file mode 100644 index 00000000..02c351fb --- /dev/null +++ b/libs/database/migrations/0008_strong_green_goblin.sql @@ -0,0 +1,17 @@ +CREATE TABLE "audit_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "entity_type" varchar(100) NOT NULL, + "action" varchar(40) NOT NULL, + "entity_id" uuid NOT NULL, + "old_values" jsonb, + "new_values" jsonb, + "organisation_id" uuid NOT NULL, + "organisation_member_id" uuid, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organisation_member_id_organisation_member_id_fk" FOREIGN KEY ("organisation_member_id") REFERENCES "public"."organisation_member"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_log_entity_id_created_at_index" ON "audit_log" USING btree ("entity_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_log_organisation_id_entity_type_created_at_index" ON "audit_log" USING btree ("organisation_id","entity_type","created_at");--> statement-breakpoint +CREATE INDEX "audit_log_organisation_member_id_created_at_index" ON "audit_log" USING btree ("organisation_member_id","created_at"); \ No newline at end of file diff --git a/libs/database/migrations/meta/0008_snapshot.json b/libs/database/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000..a6c12b0a --- /dev/null +++ b/libs/database/migrations/meta/0008_snapshot.json @@ -0,0 +1,1683 @@ +{ + "id": "e0553f1f-f542-44b0-a959-0bd5160348f9", + "prevId": "02be261a-d546-474a-9859-76e06d3d2739", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "old_values": { + "name": "old_values", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "new_values": { + "name": "new_values", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organisation_member_id": { + "name": "organisation_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_id_created_at_index": { + "name": "audit_log_entity_id_created_at_index", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_organisation_id_entity_type_created_at_index": { + "name": "audit_log_organisation_id_entity_type_created_at_index", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_organisation_member_id_created_at_index": { + "name": "audit_log_organisation_member_id_created_at_index", + "columns": [ + { + "expression": "organisation_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_organisation_id_organisation_id_fk": { + "name": "audit_log_organisation_id_organisation_id_fk", + "tableFrom": "audit_log", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_organisation_member_id_organisation_member_id_fk": { + "name": "audit_log_organisation_member_id_organisation_member_id_fk", + "tableFrom": "audit_log", + "tableTo": "organisation_member", + "columnsFrom": [ + "organisation_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_challenge": { + "name": "auth_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_otp": { + "name": "auth_otp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_otp_expires_at_index": { + "name": "auth_otp_expires_at_index", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_otp_code_index": { + "name": "auth_otp_code_index", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_otp_type_index": { + "name": "auth_otp_type_index", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_otp_user_id_user_id_fk": { + "name": "auth_otp_user_id_user_id_fk", + "tableFrom": "auth_otp", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_otp_code_type_unique": { + "name": "auth_otp_code_type_unique", + "nullsNotDistinct": false, + "columns": [ + "code", + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation_invite": { + "name": "organisation_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organisation_invite_organisation_id_email_index": { + "name": "organisation_invite_organisation_id_email_index", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_invite_email_index": { + "name": "organisation_invite_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organisation_invite_organisation_id_organisation_id_fk": { + "name": "organisation_invite_organisation_id_organisation_id_fk", + "tableFrom": "organisation_invite", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation_join_request": { + "name": "organisation_join_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "organisation_join_request_status_index": { + "name": "organisation_join_request_status_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organisation_join_request_organisation_id_organisation_id_fk": { + "name": "organisation_join_request_organisation_id_organisation_id_fk", + "tableFrom": "organisation_join_request", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organisation_join_request_user_id_user_id_fk": { + "name": "organisation_join_request_user_id_user_id_fk", + "tableFrom": "organisation_join_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation_member": { + "name": "organisation_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role": { + "name": "role", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organisation_member_user_id_user_id_fk": { + "name": "organisation_member_user_id_user_id_fk", + "tableFrom": "organisation_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "organisation_member_organisation_id_organisation_id_fk": { + "name": "organisation_member_organisation_id_organisation_id_fk", + "tableFrom": "organisation_member", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organisation_member_user_id_organisation_id_unique": { + "name": "organisation_member_user_id_organisation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "organisation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation_team_member": { + "name": "organisation_team_member", + "schema": "", + "columns": { + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organisation_team_member_team_id_organisation_team_id_fk": { + "name": "organisation_team_member_team_id_organisation_team_id_fk", + "tableFrom": "organisation_team_member", + "tableTo": "organisation_team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organisation_team_member_member_id_organisation_member_id_fk": { + "name": "organisation_team_member_member_id_organisation_member_id_fk", + "tableFrom": "organisation_team_member", + "tableTo": "organisation_member", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organisation_team_member_team_id_member_id_pk": { + "name": "organisation_team_member_team_id_member_id_pk", + "columns": [ + "team_id", + "member_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation_team": { + "name": "organisation_team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organisation_team_organisation_id_organisation_id_fk": { + "name": "organisation_team_organisation_id_organisation_id_fk", + "tableFrom": "organisation_team", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organisation_team_organisation_id_name_unique": { + "name": "organisation_team_organisation_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "organisation_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quota": { + "name": "quota", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "organisation_name_index": { + "name": "organisation_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "project_name_index": { + "name": "project_name_index", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_status_index": { + "name": "project_status_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_organisation_id_organisation_id_fk": { + "name": "project_organisation_id_organisation_id_fk", + "tableFrom": "project", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_user_id_user_id_fk": { + "name": "project_user_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_assignment": { + "name": "task_assignment", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organisation_member_id": { + "name": "organisation_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_assignment_organisation_member_id_index": { + "name": "task_assignment_organisation_member_id_index", + "columns": [ + { + "expression": "organisation_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_assignment_task_id_task_id_fk": { + "name": "task_assignment_task_id_task_id_fk", + "tableFrom": "task_assignment", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_assignment_organisation_member_id_organisation_member_id_fk": { + "name": "task_assignment_organisation_member_id_organisation_member_id_fk", + "tableFrom": "task_assignment", + "tableTo": "organisation_member", + "columnsFrom": [ + "organisation_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_assignment_task_id_organisation_member_id_pk": { + "name": "task_assignment_task_id_organisation_member_id_pk", + "columns": [ + "task_id", + "organisation_member_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "start": { + "name": "start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "recurrence_frequency": { + "name": "recurrence_frequency", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "default": "'once'" + }, + "recurrence_weekdays": { + "name": "recurrence_weekdays", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "recurrence_day_of_month": { + "name": "recurrence_day_of_month", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recurrence_end": { + "name": "recurrence_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "task_organisation_id_created_at_index": { + "name": "task_organisation_id_created_at_index", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_organisation_id_recurrence_frequency_start_index": { + "name": "task_organisation_id_recurrence_frequency_start_index", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recurrence_frequency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_organisation_id_recurrence_frequency_recurrence_end_index": { + "name": "task_organisation_id_recurrence_frequency_recurrence_end_index", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recurrence_frequency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recurrence_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_organisation_id_recurrence_frequency_recurrence_day_of_month_recurrence_end_index": { + "name": "task_organisation_id_recurrence_frequency_recurrence_day_of_month_recurrence_end_index", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recurrence_frequency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recurrence_day_of_month", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recurrence_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_project_id_project_id_fk": { + "name": "task_project_id_project_id_fk", + "tableFrom": "task", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_organisation_id_organisation_id_fk": { + "name": "task_organisation_id_organisation_id_fk", + "tableFrom": "task", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time": { + "name": "time", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "start": { + "name": "start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organisation_member_id": { + "name": "organisation_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "time_start_index": { + "name": "time_start_index", + "columns": [ + { + "expression": "start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_duration_index": { + "name": "time_duration_index", + "columns": [ + { + "expression": "duration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_project_id_project_id_fk": { + "name": "time_project_id_project_id_fk", + "tableFrom": "time", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_organisation_id_organisation_id_fk": { + "name": "time_organisation_id_organisation_id_fk", + "tableFrom": "time", + "tableTo": "organisation", + "columnsFrom": [ + "organisation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_organisation_member_id_organisation_member_id_fk": { + "name": "time_organisation_member_id_organisation_member_id_fk", + "tableFrom": "time", + "tableTo": "organisation_member", + "columnsFrom": [ + "organisation_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_task_id_task_id_fk": { + "name": "time_task_id_task_id_fk", + "tableFrom": "time", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_account": { + "name": "user_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_account_user_id_user_id_fk": { + "name": "user_account_user_id_user_id_fk", + "tableFrom": "user_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_account_account_id_provider_id_unique": { + "name": "user_account_account_id_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_credential": { + "name": "user_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_credential_user_id_user_id_fk": { + "name": "user_credential_user_id_user_id_fk", + "tableFrom": "user_credential", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_credential_user_id_id_pk": { + "name": "user_credential_user_id_id_pk", + "columns": [ + "user_id", + "id" + ] + } + }, + "uniqueConstraints": { + "user_credential_id_unique": { + "name": "user_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_email_index": { + "name": "user_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/libs/database/migrations/meta/_journal.json b/libs/database/migrations/meta/_journal.json index c1bbab62..42d2aeec 100644 --- a/libs/database/migrations/meta/_journal.json +++ b/libs/database/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1773410752642, "tag": "0007_daily_stepford_cuckoos", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1774015015333, + "tag": "0008_strong_green_goblin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/libs/database/src/audit-log.ts b/libs/database/src/audit-log.ts new file mode 100644 index 00000000..cf6c85fd --- /dev/null +++ b/libs/database/src/audit-log.ts @@ -0,0 +1,36 @@ +import { index, jsonb, pgTable, uuid, varchar } from 'drizzle-orm/pg-core'; +import { timestampColumns } from './common'; +import { organisations } from './organisation'; +import { organisationMembers } from './organisation-member'; +import { type AuditAction, type AuditEntityType } from '@zeity/types'; + +export const auditLogs = pgTable( + 'audit_log', + { + id: uuid('id').defaultRandom().notNull().primaryKey(), + + entityType: varchar('entity_type', { length: 100 }).notNull().$type(), + action: varchar('action', { length: 40 }).notNull().$type(), + entityId: uuid('entity_id').notNull(), + + oldValues: jsonb('old_values').$type | null>(), + newValues: jsonb('new_values').$type | null>(), + + organisationId: uuid('organisation_id') + .notNull() + .references(() => organisations.id, { onDelete: 'cascade' }), + organisationMemberId: uuid('organisation_member_id').references(() => organisationMembers.id, { + onDelete: 'set null', + }), + + createdAt: timestampColumns().createdAt, + }, + table => [ + index().on(table.entityId, table.createdAt), + index().on(table.organisationId, table.entityType, table.createdAt), + index().on(table.organisationMemberId, table.createdAt), + ], +); + +export type AuditLogRecord = typeof auditLogs.$inferSelect; +export type NewAuditLogRecord = typeof auditLogs.$inferInsert; diff --git a/libs/database/src/schema.ts b/libs/database/src/schema.ts index 28989c44..7c8c6c0c 100644 --- a/libs/database/src/schema.ts +++ b/libs/database/src/schema.ts @@ -16,3 +16,5 @@ export { organisationTeamMembers } from './organisation-team-member'; export { organisationMembers } from './organisation-member'; export { organisationInvites } from './organisation-invite'; export { organisationJoinRequests } from './organisation-join-request'; + +export { auditLogs } from './audit-log'; diff --git a/libs/types/src/audit-log.ts b/libs/types/src/audit-log.ts new file mode 100644 index 00000000..cd71073e --- /dev/null +++ b/libs/types/src/audit-log.ts @@ -0,0 +1,11 @@ +export const AUDIT_ACTION_CREATE = 'create' as const; +export const AUDIT_ACTION_UPDATE = 'update' as const; +export const AUDIT_ACTION_DELETE = 'delete' as const; + +export const AUDIT_ACTIONS = [AUDIT_ACTION_CREATE, AUDIT_ACTION_UPDATE, AUDIT_ACTION_DELETE]; +export type AuditAction = (typeof AUDIT_ACTIONS)[number]; + +export const AUDIT_ENTITY_TIME = 'time' as const; + +export const AUDIT_ENTITY_TYPES = [AUDIT_ENTITY_TIME]; +export type AuditEntityType = (typeof AUDIT_ENTITY_TYPES)[number]; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 216e681b..a155747e 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -3,3 +3,4 @@ export * from './project'; export * from './organisation'; export * from './user'; export * from './task'; +export * from './audit-log';