Repository: opengovsg/isomer
Pull request #1169: feat(audit-logs): add initial service + methods to log various events
Author: login: seaerchin
Created: 2025-03-12T06:39:42Z | Merged: 2025-03-19T10:32:33Z

## Problem
see related issue

## Solution
1. add various service methods to log different events
    - i didn't add `metadata` because i'm unsure what qualifies but feel free to extend the methods to add them yourself


Diff:
diff --git a/apps/studio/prisma/generated/generatedEnums.ts b/apps/studio/prisma/generated/generatedEnums.ts
index ab0dfca8b1..d8c7517ece 100644
--- a/apps/studio/prisma/generated/generatedEnums.ts
+++ b/apps/studio/prisma/generated/generatedEnums.ts
@@ -34,5 +34,8 @@ export const AuditLogEvent = {
   PermissionCreate: "PermissionCreate",
   PermissionUpdate: "PermissionUpdate",
   PermissionDelete: "PermissionDelete",
+  SiteConfigUpdate: "SiteConfigUpdate",
+  FooterUpdate: "FooterUpdate",
+  NavbarUpdate: "NavbarUpdate",
 } as const
 export type AuditLogEvent = (typeof AuditLogEvent)[keyof typeof AuditLogEvent]
diff --git a/apps/studio/prisma/generated/generatedTypes.ts b/apps/studio/prisma/generated/generatedTypes.ts
index 402fbd691a..b4f421e9f9 100644
--- a/apps/studio/prisma/generated/generatedTypes.ts
+++ b/apps/studio/prisma/generated/generatedTypes.ts
@@ -13,7 +13,7 @@ export type Generated<T> =
     : ColumnType<T, T | undefined, T>
 export type Timestamp = ColumnType<Date, Date | string, Date | string>
 
-export interface AuditLog {
+export type AuditLog = {
   id: GeneratedAlways<string>
   userId: string
   eventType: AuditLogEvent
@@ -27,7 +27,7 @@ export interface AuditLog {
   delta: PrismaJson.AuditLogDeltaJsonContent
   ipAddress: string | null
 }
-export interface Blob {
+export type Blob = {
   id: GeneratedAlways<string>
   /**
    * @kyselyType(PrismaJson.BlobJsonContent)
@@ -37,7 +37,7 @@ export interface Blob {
   createdAt: Generated<Timestamp>
   updatedAt: Generated<Timestamp>
 }
-export interface Footer {
+export type Footer = {
   id: GeneratedAlways<number>
   siteId: number
   /**
@@ -48,7 +48,7 @@ export interface Footer {
   createdAt: Generated<Timestamp>
   updatedAt: Generated<Timestamp>
 }
-export interface Navbar {
+export type Navbar = {
   id: GeneratedAlways<number>
   siteId: number
   /**
@@ -59,12 +59,12 @@ export interface Navbar {
   createdAt: Generated<Timestamp>
   updatedAt: Generated<Timestamp>
 }
-export interface RateLimiterFlexible {
+export type RateLimiterFlexible = {
   key: string
   points: number
   expire: Timestamp | null
 }
-export interface Resource {
+export type Resource = {
   id: GeneratedAlways<string>
   title: string
   permalink: string
@@ -77,7 +77,7 @@ export interface Resource {
   createdAt: Generated<Timestamp>
   updatedAt: Generated<Timestamp>
 }
-export interface ResourcePermission {
+export type ResourcePermission = {
   id: GeneratedAlways<string>
   userId: string
   siteId: number
@@ -87,7 +87,7 @@ export interface ResourcePermission {
   updatedAt: Generated<Timestamp>
   deletedAt: Timestamp | null
 }
-export interface Site {
+export type Site = {
   id: GeneratedAlways<number>
   name: string
   /**
@@ -104,7 +104,7 @@ export interface Site {
   createdAt: Generated<Timestamp>
   updatedAt: Generated<Timestamp>
 }
-export interface User {
+export type User = {
   id: string
   name: string
   email: string
@@ -114,13 +114,13 @@ export interface User {
   deletedAt: Timestamp | null
   lastLoginAt: Timestamp | null
 }
-export interface VerificationToken {
+export type VerificationToken = {
   identifier: string
   token: string
   attempts: Generated<number>
   expires: Timestamp
 }
-export interface Version {
+export type Version = {
   id: GeneratedAlways<string>
   versionNum: number
   resourceId: string
@@ -129,14 +129,14 @@ export interface Version {
   publishedBy: string
   updatedAt: Generated<Timestamp>
 }
-export interface Whitelist {
+export type Whitelist = {
   id: GeneratedAlways<number>
   email: string
   expiry: Timestamp | null
   createdAt: Generated<Timestamp>
   updatedAt: Generated<Timestamp>
 }
-export interface DB {
+export type DB = {
   AuditLog: AuditLog
   Blob: Blob
   Footer: Footer
diff --git a/apps/studio/prisma/migrations/20250318050739_add_audit_logs/migration.sql b/apps/studio/prisma/migrations/20250318080632_add_audit_logs/migration.sql
similarity index 91%
rename from apps/studio/prisma/migrations/20250318050739_add_audit_logs/migration.sql
rename to apps/studio/prisma/migrations/20250318080632_add_audit_logs/migration.sql
index d66345df64..af4018fc50 100644
--- a/apps/studio/prisma/migrations/20250318050739_add_audit_logs/migration.sql
+++ b/apps/studio/prisma/migrations/20250318080632_add_audit_logs/migration.sql
@@ -1,5 +1,5 @@
 -- CreateEnum
-CREATE TYPE "AuditLogEvent" AS ENUM ('ResourceCreate', 'ResourceUpdate', 'ResourceDelete', 'UserCreate', 'UserUpdate', 'UserDelete', 'Publish', 'Login', 'Logout', 'PermissionCreate', 'PermissionUpdate', 'PermissionDelete');
+CREATE TYPE "AuditLogEvent" AS ENUM ('ResourceCreate', 'ResourceUpdate', 'ResourceDelete', 'UserCreate', 'UserUpdate', 'UserDelete', 'Publish', 'Login', 'Logout', 'PermissionCreate', 'PermissionUpdate', 'PermissionDelete', 'SiteConfigUpdate', 'FooterUpdate', 'NavbarUpdate');
 
 -- CreateTable
 CREATE TABLE "AuditLog" (
diff --git a/apps/studio/prisma/schema.prisma b/apps/studio/prisma/schema.prisma
index b5d1e0de39..c8571a4ce1 100644
--- a/apps/studio/prisma/schema.prisma
+++ b/apps/studio/prisma/schema.prisma
@@ -119,21 +119,20 @@ model User {
   email String
   phone String
 
-  createdAt DateTime  @default(now())
-  updatedAt DateTime  @default(now()) @updatedAt
-  deletedAt DateTime?
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @default(now()) @updatedAt
+  deletedAt   DateTime?
   lastLoginAt DateTime?
 
   ResourcePermission ResourcePermission[]
   versions           Version[]
   AuditLog           AuditLog[]
 
-  @@index([lastLoginAt])
-
   // This unique index is subsequently replaced with a custom index that
   // treats nulls as not distinct.
   // This is required so prisma does not attempt to drop the custom index.
   @@unique([email, deletedAt])
+  @@index([lastLoginAt])
 }
 
 model Site {
@@ -239,6 +238,9 @@ enum AuditLogEvent {
   PermissionCreate
   PermissionUpdate
   PermissionDelete
+  SiteConfigUpdate
+  FooterUpdate
+  NavbarUpdate
 }
 
 model AuditLog {
diff --git a/apps/studio/prisma/types.ts b/apps/studio/prisma/types.ts
index ea9a2790e1..91cc908ef5 100644
--- a/apps/studio/prisma/types.ts
+++ b/apps/studio/prisma/types.ts
@@ -34,9 +34,21 @@ declare global {
       _IsomerSiteWideComponentsProps["footerItems"],
       "JSONB"
     >
-    type AuditLogDeltaJsonContent = Tagged<
-      { before: Record<string, unknown>; after: Record<string, unknown> },
-      "JSONB"
-    >
+    interface CreateLogEvent {
+      before: null
+      after: Record<string, unknown>
+    }
+    interface DeleteLogEvent {
+      before: Record<string, unknown>
+      after: null
+    }
+    interface FullLogEvent {
+      before: Record<string, unknown>
+      after: Record<string, unknown>
+    }
+    type AuditLogDeltaJsonContent =
+      | FullLogEvent
+      | CreateLogEvent
+      | DeleteLogEvent
   }
 }
diff --git a/apps/studio/src/server/modules/audit/audit.service.ts b/apps/studio/src/server/modules/audit/audit.service.ts
new file mode 100644
index 0000000000..e0b3ea5c4c
--- /dev/null
+++ b/apps/studio/src/server/modules/audit/audit.service.ts
@@ -0,0 +1,246 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Footer,
+  Navbar,
+  Resource,
+  ResourcePermission,
+  Site,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface ResourceCreateDelta {
+  before: null
+  after: FullResource
+}
+interface ResourceDeleteDelta {
+  before: FullResource
+  after: null
+}
+interface ResourceUpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: ResourceCreateDelta | ResourceDeleteDelta | ResourceUpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface SiteConfigUpdateDelta {
+  before: Site
+  after: Site
+}
+
+interface FooterUpdateDelta {
+  before: Footer
+  after: Footer
+}
+
+interface NavbarUpdateDelta {
+  before: Footer
+  after: Footer
+}
+
+type ConfigEventLogProps =
+  | FooterUpdateEventLogProps
+  | SiteConfigUpdateEventLogProps
+  | NavbarUpdateEventLogProps
+
+interface FooterUpdateEventLogProps {
+  eventType: Extract<AuditLogEvent, "FooterUpdate">
+  delta: FooterUpdateDelta
+  by: User
+  ip?: string
+}
+
+interface NavbarUpdateEventLogProps {
+  eventType: Extract<AuditLogEvent, "NavbarUpdate">
+  delta: NavbarUpdateDelta
+  by: User
+  ip?: string
+}
+
+interface SiteConfigUpdateEventLogProps {
+  eventType: Extract<AuditLogEvent, "SiteConfigUpdate">
+  delta: SiteConfigUpdateDelta
+  by: User
+  ip?: string
+}
+
+export const logConfigEvent: AuditLogger<ConfigEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({
+      eventType,
+      delta,
+      userId: by.id,
+      ipAddress: ip,
+    })
+    .execute()
+}
+
+interface LoginDelta {
+  before: { token: VerificationToken }
+  after: { token: VerificationToken; user: User }
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
+interface LogoutDelta {
+  before: User
+  after: null
+}
+
+interface AuthEventLogProps {
+  eventType: Extract<AuditLogEvent, "Login" | "Logout">
+  delta: LoginDelta | LogoutDelta
+  by: User
+  ip?: string
+}
+export const logAuthEvent: AuditLogger<AuthEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+type PublishEvents =
+  | (FullResource & { versionNumber: number })
+  | Site
+  | Navbar
+  | Footer
+
+interface PublishEventLogProps {
+  by: User
+  delta: {
+    // NOTE: `null` if this is the first publish
+    // We don't want to store the `version` because it is a pointer
+    // to the blob/resource
+    // we will instead store the full data here so it is an accurate snapshot
+    before: PublishEvents | null
+    after: PublishEvents
+  }
+  eventType: Extract<AuditLogEvent, "Publish">
+  ip?: string
+}
+export const logPublishEvent: AuditLogger<PublishEventLogProps> = async (
+  tx,
+  { by, delta, eventType, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({
+      eventType,
+      delta,
+      userId: by.id,
+      ipAddress: ip,
+    })
+    .execute()
+}
+
+interface CreateUserDelta {
+  before: null
+  after: User
+}
+
+interface DeleteUserDelta {
+  before: User
+  after: null
+}
+
+interface UpdateUserDelta {
+  before: User
+  after: User
+}
+
+interface UserEventLogProps {
+  by: User
+  delta: CreateUserDelta | DeleteUserDelta | UpdateUserDelta
+  eventType: Extract<AuditLogEvent, "UserCreate" | "UserUpdate" | "UserDelete">
+  ip?: string
+}
+
+export const logUserEvent: AuditLogger<UserEventLogProps> = async (
+  tx,
+  { by, delta, eventType, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({
+      eventType,
+      delta,
+      userId: by.id,
+      ipAddress: ip,
+    })
+    .execute()
+}
+
+interface CreatePermissionDelta {
+  before: null
+  after: ResourcePermission
+}
+
+interface DeletePermissionDelta {
+  before: null
+  after: ResourcePermission
+}
+
+interface UpdatePermissionDelta {
+  before: ResourcePermission
+  after: ResourcePermission
+}
+
+interface PermissionEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "PermissionUpdate" | "PermissionDelete" | "PermissionCreate"
+  >
+  by: User
+  delta: CreatePermissionDelta | DeletePermissionDelta | UpdatePermissionDelta
+  ip?: string
+}
+
+export const logPermissionEvent: AuditLogger<PermissionEventLogProps> = async (
+  tx,
+  { eventType, by, delta, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({
+      eventType,
+      delta,
+      userId: by.id,
+      ipAddress: ip,
+    })
+    .execute()
+}
diff --git a/apps/studio/src/server/modules/user/__tests__/user.router.test.ts b/apps/studio/src/server/modules/user/__tests__/user.router.test.ts
index 4dd267ece4..d7f392a01e 100644
--- a/apps/studio/src/server/modules/user/__tests__/user.router.test.ts
+++ b/apps/studio/src/server/modules/user/__tests__/user.router.test.ts
@@ -733,12 +733,6 @@ describe("user.router", () => {
       await setupEditorPermissions({ userId: session.userId, siteId })
 
       const user = await setupUser({ email: TEST_EMAIL, isDeleted: false })
-      await setupEditorPermissions({
-        userId: user.id,
-        siteId,
-        isDeleted: true,
-        useCurrentTime: true,
-      })
       await setupAdminPermissions({
         userId: user.id,
         siteId,


Reviews:
user: login: dcshzj
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T12:57:00Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T05:29:48Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T05:32:16Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T05:36:33Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T05:37:36Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T05:39:46Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T05:41:00Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T05:41:28Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T08:17:19Z
user: login: dcshzj
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:33:09Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T16:09:47Z
user: login: seaerchin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T16:10:03Z
user: login: dcshzj
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T03:43:52Z
user: login: dcshzj
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T03:44:42Z

Review comments:
diff_hunk: @@ -34,9 +34,18 @@ declare global {
       _IsomerSiteWideComponentsProps["footerItems"],
       "JSONB"
     >
-    type AuditLogJsonContent = Tagged<
-      { before: Record<string, unknown>; after: Record<string, unknown> },
-      "JSONB"
-    >
+    interface NewLogEvent {
+      before: null
+      after: Record<string, unknown>
+    }
+    interface DeleteLogEvent {
+      before: Record<string, unknown>
+      after: null
+    }
+    interface FullLogEvent {
+      before: Record<string, unknown>
+      after: Record<string, unknown>
+    }
+    type AuditLogJsonContent = FullLogEvent | NewLogEvent | DeleteLogEvent
path: apps/studio/prisma/types.ts
user: login: dcshzj
body: nit: maybe can call it CreateLogEvent, DeleteLogEvent and UpdateLogEvent? To align with the naming of CRUD + what you named it in the audit logs service.
created_at: 2025-03-17T12:34:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: issue: should this be `null`? Seems like we are _creating_ a user session rather than _updating_.
created_at: 2025-03-17T12:39:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: suggestion: was thinking perhaps we should also use `User` in the login to make it similar to the logout? This might make it more logical when comparing between the session creation and session destruction.

Separately, I think we might need to expand what the User object has to capture the session information, because it might be possible for a user to create multiple sessions across different devices. Ideally during login, we can give the session an ID, then use it to identify which session is being destroyed later.
created_at: 2025-03-17T12:45:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: question(non-blocking): wanted to get confirmation, we won't be logging session expiries yeah?
created_at: 2025-03-17T12:46:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: question: seems like we might be omitting changes to site config, navbar and footer? Or should they be captured as a separate AuditLogEvent?
created_at: 2025-03-17T12:51:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
+interface LogoutDelta {
+  before: User
+  after: null
+}
+
+interface AuthEventLogProps {
+  eventType: Extract<AuditLogEvent, "Login" | "Logout">
+  delta: LoginDelta | LogoutDelta
+  by: User
+  ip?: string
+}
+export const logAuthEvent: AuditLogger<AuthEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface PublishEventLogProps {
+  by: User
+  delta: {
+    // NOTE: `null` if this is the first publish
+    // We don't want to store the `version` because it is a pointer
+    // to the blob/resource
+    // we will instead store the full data here so it is an accurate snapshot
+    before: (FullResource & { versionNumber: number }) | null
+    after: FullResource & { versionNumber: number }
+  }
+  eventType: Extract<AuditLogEvent, "Publish">
+  ip?: string
+}
+export const logPublishEvent: AuditLogger<PublishEventLogProps> = async (
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: question: related to the site config, navbar and footer question above, how will we be recording publishes due to changes in those records?
created_at: 2025-03-17T12:53:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
+interface LogoutDelta {
+  before: User
+  after: null
+}
+
+interface AuthEventLogProps {
+  eventType: Extract<AuditLogEvent, "Login" | "Logout">
+  delta: LoginDelta | LogoutDelta
+  by: User
+  ip?: string
+}
+export const logAuthEvent: AuditLogger<AuthEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface PublishEventLogProps {
+  by: User
+  delta: {
+    // NOTE: `null` if this is the first publish
+    // We don't want to store the `version` because it is a pointer
+    // to the blob/resource
+    // we will instead store the full data here so it is an accurate snapshot
+    before: (FullResource & { versionNumber: number }) | null
+    after: FullResource & { versionNumber: number }
+  }
+  eventType: Extract<AuditLogEvent, "Publish">
+  ip?: string
+}
+export const logPublishEvent: AuditLogger<PublishEventLogProps> = async (
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: question(non-blocking): will resource moves, which we currently auto-publish, be recorded as two separate audit log events? I am guessing yes, but wanted to confirm with you.
created_at: 2025-03-17T12:54:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,9 +34,18 @@ declare global {
       _IsomerSiteWideComponentsProps["footerItems"],
       "JSONB"
     >
-    type AuditLogJsonContent = Tagged<
-      { before: Record<string, unknown>; after: Record<string, unknown> },
-      "JSONB"
-    >
+    interface NewLogEvent {
+      before: null
+      after: Record<string, unknown>
+    }
+    interface DeleteLogEvent {
+      before: Record<string, unknown>
+      after: null
+    }
+    interface FullLogEvent {
+      before: Record<string, unknown>
+      after: Record<string, unknown>
+    }
+    type AuditLogJsonContent = FullLogEvent | NewLogEvent | DeleteLogEvent
path: apps/studio/prisma/types.ts
user: login: seaerchin
body: sounds good

created_at: 2025-03-18T05:29:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: eh not quite! this is because our login is a 2 step process
1. request for otp (audit log not called, verification token created)
2. verify otp (audit log called, verification token updated) 

in other words, we're discarding step 1 here - this is because we rate limit + no user has actually logged in yet, so i felt this wasn't useful to keep. 

lmk if you have the same thoughts
created_at: 2025-03-18T05:32:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: the `VerificationToken` here consists of `user.email` with the `ip` (see ss). because of this, it's possible for the user to have multiple session over devices **but** this will be captured under teh `ip` section (distinct ip) though there is a short-coming in that the user isn't logged directly; i'll add it in!

<img width="1182" alt="image" src="https://github.com/user-attachments/assets/aa0a832f-115e-46c6-afe6-6c39e8c11528" />

created_at: 2025-03-18T05:36:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: session expiry calls `logout` isn't it? 
created_at: 2025-03-18T05:37:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: oh good catch - for now, we don't have user capability to add it in so it'll have to be through the admin endpoint. i think it'll be a different `AuditLogEvent` because we want to capture the full db item 
created_at: 2025-03-18T05:39:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
+interface LogoutDelta {
+  before: User
+  after: null
+}
+
+interface AuthEventLogProps {
+  eventType: Extract<AuditLogEvent, "Login" | "Logout">
+  delta: LoginDelta | LogoutDelta
+  by: User
+  ip?: string
+}
+export const logAuthEvent: AuditLogger<AuthEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface PublishEventLogProps {
+  by: User
+  delta: {
+    // NOTE: `null` if this is the first publish
+    // We don't want to store the `version` because it is a pointer
+    // to the blob/resource
+    // we will instead store the full data here so it is an accurate snapshot
+    before: (FullResource & { versionNumber: number }) | null
+    after: FullResource & { versionNumber: number }
+  }
+  eventType: Extract<AuditLogEvent, "Publish">
+  ip?: string
+}
+export const logPublishEvent: AuditLogger<PublishEventLogProps> = async (
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: yes, i think each "event" should be recorded as a unit of change to some table. since this involves 2 changes to 2 tables, we shud log as 2 events
created_at: 2025-03-18T05:41:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
+interface LogoutDelta {
+  before: User
+  after: null
+}
+
+interface AuthEventLogProps {
+  eventType: Extract<AuditLogEvent, "Login" | "Logout">
+  delta: LoginDelta | LogoutDelta
+  by: User
+  ip?: string
+}
+export const logAuthEvent: AuditLogger<AuthEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface PublishEventLogProps {
+  by: User
+  delta: {
+    // NOTE: `null` if this is the first publish
+    // We don't want to store the `version` because it is a pointer
+    // to the blob/resource
+    // we will instead store the full data here so it is an accurate snapshot
+    before: (FullResource & { versionNumber: number }) | null
+    after: FullResource & { versionNumber: number }
+  }
+  eventType: Extract<AuditLogEvent, "Publish">
+  ip?: string
+}
+export const logPublishEvent: AuditLogger<PublishEventLogProps> = async (
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: good question - i actually missed this out. lemme think abit more HAHA
created_at: 2025-03-18T05:41:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
+interface LogoutDelta {
+  before: User
+  after: null
+}
+
+interface AuthEventLogProps {
+  eventType: Extract<AuditLogEvent, "Login" | "Logout">
+  delta: LoginDelta | LogoutDelta
+  by: User
+  ip?: string
+}
+export const logAuthEvent: AuditLogger<AuthEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface PublishEventLogProps {
+  by: User
+  delta: {
+    // NOTE: `null` if this is the first publish
+    // We don't want to store the `version` because it is a pointer
+    // to the blob/resource
+    // we will instead store the full data here so it is an accurate snapshot
+    before: (FullResource & { versionNumber: number }) | null
+    after: FullResource & { versionNumber: number }
+  }
+  eventType: Extract<AuditLogEvent, "Publish">
+  ip?: string
+}
+export const logPublishEvent: AuditLogger<PublishEventLogProps> = async (
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: ok, went with the simple solution of just defining a new type
created_at: 2025-03-18T08:17:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
+interface LogoutDelta {
+  before: User
+  after: null
+}
+
+interface AuthEventLogProps {
+  eventType: Extract<AuditLogEvent, "Login" | "Logout">
+  delta: LoginDelta | LogoutDelta
+  by: User
+  ip?: string
+}
+export const logAuthEvent: AuditLogger<AuthEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface PublishEventLogProps {
+  by: User
+  delta: {
+    // NOTE: `null` if this is the first publish
+    // We don't want to store the `version` because it is a pointer
+    // to the blob/resource
+    // we will instead store the full data here so it is an accurate snapshot
+    before: (FullResource & { versionNumber: number }) | null
+    after: FullResource & { versionNumber: number }
+  }
+  eventType: Extract<AuditLogEvent, "Publish">
+  ip?: string
+}
+export const logPublishEvent: AuditLogger<PublishEventLogProps> = async (
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: Yup thanks! Means my understanding is the same.
created_at: 2025-03-18T14:15:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: Yup I think when we should create an audit log makes sense and aligned with what I understand as well.

But I'm a bit not quite understanding about the verification token part. When the user successfully verifies the OTP, we are recording the `before` with the verification token + the total number of attempts made, then the `after` would be the same verification token + the total number of attempts made, plus the User object. However, if we are reading only the AuditLogs table, this means that there is supposed to be an earlier entry that is missing.

So this means that the state of items are meant to be something like NULL -> Object -> NULL, and the state changes are the audit log events:
1. NULL -> Object indicates creation (in this case a session is created, i.e. login)
2. Object -> NULL indicates deletion (in this case the session is destroyed, i.e. logout)

If we are representing Login as a session creation (which happens only after successful verification of OTP), then it should be the case of `before` being NULL and `after` being the object we want to store.
created_at: 2025-03-18T14:30:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: Hmm I think not really. The session is set using a cookie with an expiry date, so when the cookie expires it is automatically removed by the browser, and Studio treats the user as if they have never logged in before, so `logout` is not called.
created_at: 2025-03-18T14:32:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: when the user successfully verifies their otp, we delete the token. hence, when we record a successful login (and the corresponding token), we only need to record the token -> user transition

hwen logout is called, there's actually no db updates, just that we log it for the sake of completeness (argument can be made for not logging too ah)
created_at: 2025-03-18T16:09:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
+  after: VerificationToken
+}
+
+// NOTE: logout just calls `session.destroy` and we only have
+// `userId` or sgid info in session.
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: seaerchin
body: eh yea you're right, i m mistaken! 
created_at: 2025-03-18T16:10:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import type {
+  AuditLogEvent,
+  Blob,
+  DB,
+  Resource,
+  ResourcePermission,
+  Transaction,
+  User,
+  VerificationToken,
+} from "../database"
+
+type FullResource = Resource & (Blob | undefined)
+
+interface CreateDelta {
+  before: null
+  after: FullResource
+}
+interface DeleteDelta {
+  before: FullResource
+  after: null
+}
+interface UpdateDelta {
+  before: FullResource
+  after: FullResource
+}
+
+interface ResourceEventLogProps {
+  eventType: Extract<
+    AuditLogEvent,
+    "ResourceCreate" | "ResourceUpdate" | "ResourceDelete" | "ResourceMove"
+  >
+  delta: CreateDelta | DeleteDelta | UpdateDelta
+  by: User
+  ip?: string
+}
+
+// NOTE: Type to force every logger method to have a tx
+type AuditLogger<T> = (tx: Transaction<DB>, props: T) => Promise<void>
+
+export const logResourceEvent: AuditLogger<ResourceEventLogProps> = async (
+  tx,
+  { eventType, delta, by, ip },
+) => {
+  await tx
+    .insertInto("AuditLog")
+    .values({ eventType, delta, userId: by.id, ipAddress: ip })
+    .execute()
+}
+
+interface LoginDelta {
+  before: VerificationToken
path: apps/studio/src/server/modules/audit/audit.service.ts
user: login: dcshzj
body: Clarified offline, intent is more to capture DB-level changes and regardless of the `before` and `after`, we still fulfil the agency's intent of capturing log ins via the Login AuditLogEvent.

Caveat is that the number of attempts in the verification token should be X in `before` and X+1 in `after`. Meaning if the user enters the OTP correctly on the first try, `before` should be recorded as 0 and `after` should be recorded as 1.
created_at: 2025-03-19T03:43:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-03-12T06:39:45Z
body: <p><a href="https://linear.app/ogp/issue/ISOM-1754/[be]-create-audit-log-service">ISOM-1754 [BE] Create audit log service</a></p>
author_association: NONE
user: login: datadog-opengovsg[bot]
created_at: 2025-03-12T06:44:23Z
body: 
## Datadog Report

Branch report: [`chin/isom-1754-be-create-audit-log-service`](https://ogp.datadoghq.com/ci/test-branch/github.com%2Fopengovsg%2Fisomer/isomer-studio/chin%2Fisom-1754-be-create-audit-log-service?env=ci&index=citest&_user_click_origin_=github)
Commit report: [`3269fd7`](https://ogp.datadoghq.com/ci/test-commit/github.com%2Fopengovsg%2Fisomer/isomer-studio/chin%2Fisom-1754-be-create-audit-log-service/3269fd7da96285bdffa180c6c1a80f00158fe233?env=ci&index=citest&_user_click_origin_=github)
Test service: `isomer-studio`

:white_check_mark: 0 Failed, 414 Passed, 40 Skipped, 1m 2.1s Total Time
:arrow_right: Test Sessions change in coverage: 1 no change



author_association: NONE
user: login: seaerchin
created_at: 2025-03-13T03:36:17Z
body: * **#1169** <a href="https://app.graphite.dev/github/pr/opengovsg/isomer/1169?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 2  dependent PRs ([#1170](https://github.com/opengovsg/isomer/pull/1170) <a href="https://app.graphite.dev/github/pr/opengovsg/isomer/1170?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>, [#1175](https://github.com/opengovsg/isomer/pull/1175) <a href="https://app.graphite.dev/github/pr/opengovsg/isomer/1175?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>) 👈 <a href="https://app.graphite.dev/github/pr/opengovsg/isomer/1169?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#1167** <a href="https://app.graphite.dev/github/pr/opengovsg/isomer/1167?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: seaerchin
created_at: 2025-03-19T10:32:34Z
body: ### Merge activity

* **Mar 19, 6:32 AM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/opengovsg/isomer/1169).

author_association: CONTRIBUTOR