Repository: safeinsights/management-app
Pull request #180: Event auditing
Author: login: sparksam
Created: 2025-05-05T17:05:51Z | Merged: 2025-05-06T16:11:18Z

Audit logs are currently recorded for Studies approval and rejection.


Diff:
diff --git a/src/database/migrations/1746109057988_audit.ts b/src/database/migrations/1746109057988_audit.ts
new file mode 100644
index 000000000..f4bbe4d88
--- /dev/null
+++ b/src/database/migrations/1746109057988_audit.ts
@@ -0,0 +1,19 @@
+import { type Kysely, sql } from 'kysely'
+
+export async function up(db: Kysely<unknown>): Promise<void> {
+    await db.schema.createType('audit_event_type').asEnum(['APPROVED', 'REJECTED']).execute()
+    await db.schema.createType('audit_record_type').asEnum(['STUDY']).execute()
+    await db.schema
+        .createTable('audit')
+        .addColumn('id', 'uuid', (col) => col.defaultTo(sql`v7uuid()`).primaryKey())
+        .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`now()`).notNull())
+        .addColumn('event_type', sql`audit_event_type`, (col) => col.notNull())
+        .addColumn('record_id', 'uuid', (col) => col.notNull())
+        .addColumn('record_type', sql`audit_record_type`, (col) => col.notNull())
+        .addColumn('userId', 'uuid', (col) => col.notNull())
+        .execute()
+}
+
+export async function down(db: Kysely<unknown>): Promise<void> {
+    await db.schema.dropTable('audit').execute()
+}
diff --git a/src/database/types.ts b/src/database/types.ts
index 22ecf3e73..5c9cda84d 100644
--- a/src/database/types.ts
+++ b/src/database/types.ts
@@ -5,6 +5,10 @@
 
 import type { ColumnType } from "kysely"
 
+export type AuditEventType = "APPROVED" | "REJECTED";
+
+export type AuditRecordType = "STUDY";
+
 export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
   ? ColumnType<S, I | undefined, U>
   : ColumnType<T, T | undefined, T>;
@@ -17,6 +21,15 @@ export type StudyStatus = "APPROVED" | "ARCHIVED" | "INITIATED" | "PENDING-REVIE
 
 export type Timestamp = ColumnType<Date, Date | string, Date | string>;
 
+export interface Audit {
+  createdAt: Generated<Timestamp>;
+  eventType: AuditEventType;
+  id: Generated<string>;
+  recordId: string;
+  recordType: AuditRecordType;
+  userId: string;
+}
+
 export interface JobStatusChange {
   createdAt: Generated<Timestamp>;
   id: Generated<string>;
@@ -95,6 +108,7 @@ export interface UserPublicKey {
 }
 
 export interface DB {
+  audit: Audit;
   jobStatusChange: JobStatusChange;
   org: Org;
   orgUser: OrgUser;
diff --git a/src/lib/audit.ts b/src/lib/audit.ts
new file mode 100644
index 000000000..79d3ca0f4
--- /dev/null
+++ b/src/lib/audit.ts
@@ -0,0 +1,25 @@
+import { db } from '@/database'
+import { AuditEventType, AuditRecordType } from '@/database/types'
+import debug from 'debug'
+
+const auditDebug = debug('app:audit')
+
+type AuditEntry = {
+    eventType: AuditEventType
+    userId: string
+    recordType: AuditRecordType
+    recordId: string
+}
+
+export const audit = async (entry: AuditEntry): Promise<void> => {
+    auditDebug(`Auditing ${entry.eventType}: ${entry.recordType}/${entry.recordId}`)
+    await db
+        .insertInto('audit')
+        .values({
+            userId: entry.userId,
+            eventType: entry.eventType,
+            recordType: entry.recordType,
+            recordId: entry.recordId,
+        })
+        .execute()
+}
diff --git a/src/server/actions/study.actions.ts b/src/server/actions/study.actions.ts
index 13d9ae390..21f75df28 100644
--- a/src/server/actions/study.actions.ts
+++ b/src/server/actions/study.actions.ts
@@ -3,6 +3,14 @@
 import { db } from '@/database'
 import { revalidatePath } from 'next/cache'
 
+import { StudyJobStatus } from '@/database/types'
+import { audit } from '@/lib/audit'
+import logger from '@/lib/logger'
+import { latestJobForStudy } from '@/server/db/queries'
+import { sendStudyProposalApprovedEmail, sendStudyProposalRejectedEmail } from '@/server/mailgun'
+import { triggerBuildImageForJob } from '../aws'
+import { SIMULATE_IMAGE_BUILD } from '../config'
+import { checkUserAllowedStudyReview } from '../db/queries'
 import {
     checkMemberOfOrgWithSlug,
     getUserIdFromActionContext,
@@ -11,13 +19,6 @@ import {
     userAction,
     z,
 } from './wrappers'
-import { latestJobForStudy } from '@/server/db/queries'
-import { checkUserAllowedStudyReview } from '../db/queries'
-import { StudyJobStatus } from '@/database/types'
-import { SIMULATE_IMAGE_BUILD } from '../config'
-import { triggerBuildImageForJob } from '../aws'
-import logger from '@/lib/logger'
-import { sendStudyProposalApprovedEmail, sendStudyProposalRejectedEmail } from '@/server/mailgun'
 
 export const fetchStudiesForOrgAction = orgAction(
     async ({ orgSlug }) => {
@@ -213,6 +214,12 @@ export const approveStudyProposalAction = orgAction(
                 .executeTakeFirstOrThrow()
         })
 
+        await audit({
+            eventType: 'APPROVED',
+            userId,
+            recordType: 'STUDY',
+            recordId: studyId,
+        })
         await sendStudyProposalApprovedEmail(studyId)
 
         logger.info('Study Approved', {
@@ -255,6 +262,12 @@ export const rejectStudyProposalAction = orgAction(
                 .executeTakeFirstOrThrow()
         })
 
+        await audit({
+            eventType: 'REJECTED',
+            userId,
+            recordType: 'STUDY',
+            recordId: studyId,
+        })
         await sendStudyProposalRejectedEmail(studyId)
 
         logger.info('Study Rejected', {


Reviews:
user: login: nathanstitt
body: Looking great, thank you @sparksam !
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T17:15:13Z
user: login: sparksam
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T17:25:42Z
user: login: chrisbendel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T17:28:35Z
user: login: sparksam
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T17:30:44Z
user: login: nathanstitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T18:46:19Z
user: login: chrisbendel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T15:58:24Z
user: login: chrisbendel
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T15:59:10Z
user: login: nathanstitt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T16:01:01Z
user: login: sparksam
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:03:04Z
user: login: nathanstitt
body: looks great, thank you so much @sparksam 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-06T16:11:04Z

Review comments:
diff_hunk: @@ -0,0 +1,21 @@
+import { type Kysely, sql } from 'kysely'
+
+// biome-ignore lint/suspicious/noExplicitAny: `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
+export async function up(db: Kysely<unknown>): Promise<void> {
+    await db.schema.createType('audit_event_type').asEnum(['APPROVED', 'REJECTED']).execute()
+    await db.schema.createType('audit_record_type').asEnum(['STUDY']).execute()
+    await db.schema
+        .createTable('audit')
+        .addColumn('id', 'uuid', (col) => col.defaultTo(sql`v7uuid()`).primaryKey())
+        .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`now()`).notNull())
+        .addColumn('event_type', sql`audit_event_type`, (col) => col.notNull())
+        .addColumn('record_id', 'text', (col) => col.notNull())
+        .addColumn('record_type', sql`audit_record_type`, (col) => col.notNull())
+        .addColumn('userId', 'text', (col) => col.notNull())
path: src/database/migrations/1746109057988_audit.ts
user: login: nathanstitt
body: the record_id and user_id should both be type of uuid
created_at: 2025-05-05T17:11:45Z
author_association: MEMBER
diff_hunk: @@ -255,6 +257,7 @@ export const rejectStudyProposalAction = orgAction(
                 .executeTakeFirstOrThrow()
         })
 
+        await audit('REJECTED', userId, 'STUDY', studyId)
path: src/server/actions/study.actions.ts
user: login: nathanstitt
body: I would find it easier to follow if the `audit` table accepted an object:
```suggestion
        await audit({ eventType: 'REJECTED', userId,  recordType: 'STUDY', recordId: studyId })
```
created_at: 2025-05-05T17:14:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,21 @@
+/* eslint-disable no-console */
+import { db } from '@/database'
+import { AuditEventType, AuditRecordType } from '@/database/types'
+
+export const audit = async (
+    eventType: AuditEventType,
+    userId: string,
+    recordType: AuditRecordType,
+    recordId: string,
+): Promise<void> => {
+    console.log(`Auditing ${eventType}: ${recordType}/${recordId}`)
+    await db
+        .insertInto('audit')
+        .values({
+            userId: userId,
+            eventType: eventType,
+            recordType: recordType,
+            recordId: recordId,
+        })
+        .execute()
path: src/lib/audit.ts
user: login: nathanstitt
body: I'd suggest `audit` function accept an object that could be passed as-is into `values` 
created_at: 2025-05-05T17:14:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,21 @@
+/* eslint-disable no-console */
+import { db } from '@/database'
+import { AuditEventType, AuditRecordType } from '@/database/types'
+
+export const audit = async (
+    eventType: AuditEventType,
+    userId: string,
+    recordType: AuditRecordType,
+    recordId: string,
+): Promise<void> => {
+    console.log(`Auditing ${eventType}: ${recordType}/${recordId}`)
+    await db
+        .insertInto('audit')
+        .values({
+            userId: userId,
+            eventType: eventType,
+            recordType: recordType,
+            recordId: recordId,
+        })
+        .execute()
path: src/lib/audit.ts
user: login: sparksam
body: Thanks for the suggestions @nathanstitt! I addressed the review comments. 
created_at: 2025-05-05T17:25:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+/* eslint-disable no-console */
+import { db } from '@/database'
+import { AuditEventType, AuditRecordType } from '@/database/types'
+
+type AuditEntry = {
+    eventType: AuditEventType
+    userId: string
+    recordType: AuditRecordType
+    recordId: string
+}
+
+export const audit = async (entry: AuditEntry): Promise<void> => {
+    console.log(`Auditing ${entry.eventType}: ${entry.recordType}/${entry.recordId}`)
path: src/lib/audit.ts
user: login: chrisbendel
body: do we need console.log here?
created_at: 2025-05-05T17:28:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+/* eslint-disable no-console */
+import { db } from '@/database'
+import { AuditEventType, AuditRecordType } from '@/database/types'
+
+type AuditEntry = {
+    eventType: AuditEventType
+    userId: string
+    recordType: AuditRecordType
+    recordId: string
+}
+
+export const audit = async (entry: AuditEntry): Promise<void> => {
+    console.log(`Auditing ${entry.eventType}: ${entry.recordType}/${entry.recordId}`)
path: src/lib/audit.ts
user: login: sparksam
body: Per requirement, no. But I added to have an audit trail in the log. I can remove it if not needed. 
created_at: 2025-05-05T17:30:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+/* eslint-disable no-console */
+import { db } from '@/database'
+import { AuditEventType, AuditRecordType } from '@/database/types'
+
+type AuditEntry = {
+    eventType: AuditEventType
+    userId: string
+    recordType: AuditRecordType
+    recordId: string
+}
+
+export const audit = async (entry: AuditEntry): Promise<void> => {
+    console.log(`Auditing ${entry.eventType}: ${entry.recordType}/${entry.recordId}`)
path: src/lib/audit.ts
user: login: nathanstitt
body: instead of console.log, maybe create a log using `debug`.  we have an example of using that in `src/middleware`
created_at: 2025-05-05T18:46:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,21 @@
+import { type Kysely, sql } from 'kysely'
+
+// biome-ignore lint/suspicious/noExplicitAny: `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
path: src/database/migrations/1746109057988_audit.ts
user: login: chrisbendel
body: is this needed or just some editor specific thing?
created_at: 2025-05-06T15:58:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+import { type Kysely, sql } from 'kysely'
+
+// biome-ignore lint/suspicious/noExplicitAny: `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
path: src/database/migrations/1746109057988_audit.ts
user: login: nathanstitt
body: yes good catch @chrisbendel - kysely migrate adds these automatically.    I've been meaning to investigate `biome` since it does sounds like a neat tool vs our eslint+prettier

@sparksam can you remove the comments and replace the `any` with `unknown` please?
created_at: 2025-05-06T16:01:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,21 @@
+import { type Kysely, sql } from 'kysely'
+
+// biome-ignore lint/suspicious/noExplicitAny: `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
path: src/database/migrations/1746109057988_audit.ts
user: login: sparksam
body: I replaced the `any` with `unknown`, will remove the auto generated comment. 
created_at: 2025-05-06T16:03:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-06T16:08:07Z
body: 
### Total coverage

| Lines           | Branches           | Functions           | Statements           |
| --------------- | ------------------ | ------------------- | -------------------- |
| 87.87% (+0.00%) | 61.65% (+0.00%) | 83.30% (+0.02%) | 86.51% (+0.00%) | 


#### Detailed report

<details><summary>1 file with a coverage regression</summary>

|   | File | Lines | Branches | Functions | Statements |
| - | ---- | ----- | -------- | --------- | ---------- |
| ✅ | src/lib/audit.ts | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) | 100.00% (+100.00%) |,
| 🚫 | src/server/actions/study.actions.ts | 71.42% (-0.31%) | 33.33% | 60.00% | 69.81% (-0.77%) |
</details>

<!-- This comment was produced by coverage-diff-action -->
author_association: CONTRIBUTOR