Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions apps/zeity/server/api/times/[id].delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 ||
Expand All @@ -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);
});
33 changes: 23 additions & 10 deletions apps/zeity/server/api/times/[id].patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 ||
Expand All @@ -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!,
},
});
});
});
50 changes: 33 additions & 17 deletions apps/zeity/server/api/times/index.post.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, unknown>,
organisationId: organisation.value,
organisationMemberId,
},
});
}

return result;
return result;
});
});
41 changes: 29 additions & 12 deletions apps/zeity/server/api/times/sync.post.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down Expand Up @@ -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;
});
});
11 changes: 11 additions & 0 deletions apps/zeity/server/utils/audit-log.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useDrizzle>;
return db.insert(auditLogs).values(event);
}

export function logAuditEvents({ tx, events }: { tx?: unknown; events: NewAuditLogRecord[] }) {
const db = (tx ?? useDrizzle()) as ReturnType<typeof useDrizzle>;
return db.insert(auditLogs).values(events);
}
17 changes: 17 additions & 0 deletions libs/database/migrations/0008_strong_green_goblin.sql
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +9 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve actor attribution after member deletion.

When an organisation_member row is hard-deleted, Line 14 nulls the dedicated actor reference on the audit row. The current writers use organisation_member_id for attribution—for example, apps/zeity/server/api/times/sync.post.ts Lines 94-95—so those historical events no longer have a reliable “who did this?” field. Consider storing an immutable actor key/snapshot on the audit row, or using an FK strategy that does not erase attribution.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/database/migrations/0008_strong_green_goblin.sql` around lines 9 - 14,
The audit rows lose actor attribution because the FK
audit_log.organisation_member_id uses ON DELETE SET NULL; to fix, add an
immutable actor snapshot column (e.g., actor_organisation_member_key TEXT or
actor_snapshot JSONB) to the audit_log table and update writers (e.g., the place
referenced in apps/zeity/server/api/times/sync.post.ts where
organisation_member_id is set) to populate this new column with the member’s
stable identifier or snapshot at write time, and also change or recreate the FK
constraint audit_log_organisation_member_id_organisation_member_id_fk to use ON
DELETE NO ACTION/RESTRICT (or remove the FK) so historical rows do not lose the
original organisation_member_id reference if a member is hard-deleted.

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");
Loading
Loading