From 8b30f743cc8810061c60d12ae9554325ad686eeb Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Thu, 30 Apr 2026 16:39:46 +0530 Subject: [PATCH 1/8] feat: add prisma extension for auditlogs --- .../migration.sql | 16 ++++++ prisma/schema.prisma | 51 +++++++++++------- src/app.module.ts | 19 ++++++- src/auth/auth.controller.ts | 4 ++ src/auth/guard/auth.guard.ts | 21 ++++++-- src/common/middleware/index.ts | 1 + .../middleware/request-context.middleware.ts | 20 +++++++ src/infra/als/als.module.ts | 9 ++++ src/infra/als/als.service.ts | 5 ++ src/infra/prisma/prisma.extension.ts | 54 +++++++++++++++++++ src/infra/prisma/prisma.service.ts | 14 ++++- src/user/user.controller.ts | 2 + src/user/user.service.ts | 2 +- 13 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 prisma/migrations/20260430073327_add_audit_log_model/migration.sql create mode 100644 src/common/middleware/index.ts create mode 100644 src/common/middleware/request-context.middleware.ts create mode 100644 src/infra/als/als.module.ts create mode 100644 src/infra/als/als.service.ts create mode 100644 src/infra/prisma/prisma.extension.ts diff --git a/prisma/migrations/20260430073327_add_audit_log_model/migration.sql b/prisma/migrations/20260430073327_add_audit_log_model/migration.sql new file mode 100644 index 0000000..c3cf26e --- /dev/null +++ b/prisma/migrations/20260430073327_add_audit_log_model/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" TEXT NOT NULL, + "entity" TEXT NOT NULL, + "entity_id" TEXT NOT NULL, + "old_data" JSONB, + "new_data" JSONB, + "url" TEXT, + "user_id" INTEGER, + "user_email" TEXT, + "ip_address" TEXT, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9a3769f..e1ea2c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,6 @@ // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client" output = "../generated/prisma" @@ -15,18 +12,18 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp() - deletedAt DateTime? @map("deleted_at") - - email String @unique - password String - firstName String? @map("first_name") - lastName String? @map("last_name") - role Role @default(USER) - tokenVersion Int @default(1) @map("token_version") - + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp() + deletedAt DateTime? @map("deleted_at") + // + email String @unique + password String + firstName String? @map("first_name") + lastName String? @map("last_name") + role Role @default(USER) + tokenVersion Int @default(1) @map("token_version") + // refreshTokens RefreshToken[] @@map("users") @@ -43,11 +40,29 @@ model RefreshToken { id String @id @db.Uuid userId Int @map("user_id") createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() - + // token String expiresAt DateTime @map("expires_at") @db.Timestamp() - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // + user User @relation(references: [id], fields: [userId], onDelete: Cascade) @@map("refresh_tokens") } + +model AuditLog { + id String @id @default(uuid()) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() + // + action String // e.g., "CREATE", "UPDATE", "DELETE" + entity String // e.g., "User", "Product" + entityId String @map("entity_id") + oldData Json? @map("old_data") + newData Json? @map("new_data") + // + url String? + userId Int? @map("user_id") // ID of the user who performed the action + userEmail String? @map("user_email") // Email of the user who performed the action + ipAddress String? @map("ip_address") + + @@map("audit_logs") +} diff --git a/src/app.module.ts b/src/app.module.ts index 20fb841..53f2f11 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,10 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' -import { Module } from '@nestjs/common' +import { + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_GUARD } from '@nestjs/core' import { ScheduleModule } from '@nestjs/schedule' @@ -8,7 +13,9 @@ import { AppController } from './app.controller' import { AppService } from './app.service' import { AuthInfrastructureModule } from './auth/auth-infrastructure.module' import { AuthModule } from './auth/auth.module' +import { RequestContextMiddleware } from './common/middleware' import envValidation from './config/env.validation' +import { AlsModule } from './infra/als/als.module' import { PrismaModule } from './infra/prisma/prisma.module' import { RedisModule } from './infra/redis/redis.module' import { RedisService } from './infra/redis/redis.service' @@ -38,6 +45,7 @@ import { UserModule } from './user/user.module' }), }), ScheduleModule.forRoot(), + AlsModule, ], controllers: [AppController], providers: [ @@ -48,4 +56,11 @@ import { UserModule } from './user/user.module' }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(RequestContextMiddleware) + .exclude({ path: '*path', method: RequestMethod.GET }) + .forRoutes({ path: '*path', method: RequestMethod.ALL }) + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c331c98..6c03d77 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -33,6 +33,7 @@ export class AuthController { ) {} @Public() + // @UseInterceptors(AuditInterceptor) @Post('signup') signup(@Body() createUserDto: CreateUserDto) { return this.authService.signup(createUserDto) @@ -74,6 +75,7 @@ export class AuthController { } @ApiBearerAuth() + // @UseInterceptors(AuditInterceptor) @Patch('change-email') changeEmail( @User('email') oldEmail: string, @@ -83,6 +85,7 @@ export class AuthController { } @ApiBearerAuth() + // @UseInterceptors(AuditInterceptor) @Patch('change-password') changePassword( @User('email') email: string, @@ -92,6 +95,7 @@ export class AuthController { } @Public() + // @UseInterceptors(AuditInterceptor) @Patch('forgot-password') forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { return this.authService.forgotPassword(forgotPasswordDto) diff --git a/src/auth/guard/auth.guard.ts b/src/auth/guard/auth.guard.ts index 352d214..6bc0f9c 100644 --- a/src/auth/guard/auth.guard.ts +++ b/src/auth/guard/auth.guard.ts @@ -11,6 +11,7 @@ import type { ConfigType } from '@nestjs/config' import { Reflector } from '@nestjs/core' import { JsonWebTokenError, JwtService } from '@nestjs/jwt' import { Request } from 'express' +import { AlsService } from 'src/infra/als/als.service' import { PrismaService } from 'src/infra/prisma/prisma.service' import authConfig from '../config/auth.config' import { IS_PUBLIC_KEY } from '../decorator' @@ -22,11 +23,12 @@ export const REQUEST_USER_KEY = 'user' @Injectable() export class AuthGuard implements CanActivate { constructor( - private jwtService: JwtService, - private reflector: Reflector, + private readonly jwtService: JwtService, + private readonly reflector: Reflector, @Inject(authConfig.KEY) private readonly authConfiguration: ConfigType, - private prisma: PrismaService, + private readonly prisma: PrismaService, + private readonly als: AlsService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -65,11 +67,20 @@ export class AuthGuard implements CanActivate { throw new UnauthorizedException('Access token revoked') } - request[REQUEST_USER_KEY] = { + const activeUser: ActiveUser = { sub: payload.sub, email: user.email, role: user.role, - } as ActiveUser + } + + request[REQUEST_USER_KEY] = activeUser + + // update the ALS store with the verified user + const store = this.als.getStore() + if (store) { + store.set('userId', activeUser.sub) + store.set('userEmail', activeUser.email) + } } catch (error) { if (error instanceof JsonWebTokenError) { throw new UnauthorizedException(error) diff --git a/src/common/middleware/index.ts b/src/common/middleware/index.ts new file mode 100644 index 0000000..693e176 --- /dev/null +++ b/src/common/middleware/index.ts @@ -0,0 +1 @@ +export * from './request-context.middleware' diff --git a/src/common/middleware/request-context.middleware.ts b/src/common/middleware/request-context.middleware.ts new file mode 100644 index 0000000..ffc8da6 --- /dev/null +++ b/src/common/middleware/request-context.middleware.ts @@ -0,0 +1,20 @@ +import { Injectable, NestMiddleware } from '@nestjs/common' +import { NextFunction, Request } from 'express' +import { AlsService } from 'src/infra/als/als.service' + +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + constructor(private readonly als: AlsService) {} + + use(req: Request, res: Response, next: NextFunction) { + // const mutationMethods = ['POST', 'PUT', 'PATCH', 'DELETE'] + // If it's a GET or HEAD request, just skip the ALS logic + // if (!mutationMethods.includes(req.method)) return next() + + const store: Map = new Map() + store.set('ip', req.ip) + store.set('url', req.url) + + this.als.run(store, () => next()) + } +} diff --git a/src/infra/als/als.module.ts b/src/infra/als/als.module.ts new file mode 100644 index 0000000..1976f33 --- /dev/null +++ b/src/infra/als/als.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common' +import { AlsService } from './als.service' + +@Global() +@Module({ + providers: [AlsService], + exports: [AlsService], +}) +export class AlsModule {} diff --git a/src/infra/als/als.service.ts b/src/infra/als/als.service.ts new file mode 100644 index 0000000..8ff9727 --- /dev/null +++ b/src/infra/als/als.service.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common' +import { AsyncLocalStorage } from 'async_hooks' + +@Injectable() +export class AlsService extends AsyncLocalStorage> {} diff --git a/src/infra/prisma/prisma.extension.ts b/src/infra/prisma/prisma.extension.ts new file mode 100644 index 0000000..107dc1c --- /dev/null +++ b/src/infra/prisma/prisma.extension.ts @@ -0,0 +1,54 @@ +import { PrismaClient } from 'generated/prisma/client' +import { AlsService } from '../als/als.service' + +export const auditLogExtension = (client: PrismaClient, als: AlsService) => { + return client.$extends({ + query: { + $allModels: { + async update({ model, args, query }) { + if (model === 'AuditLog') return query(args) + + // const oldData = await (client as any)[model].findUnique({ + // where: args.where, + // }) + + // Get the data from the ALS pocket! + const store = als.getStore() + const userIdValue: unknown = store?.get('userId') + const userEmailValue: unknown = store?.get('userEmail') + const ipValue: unknown = store?.get('ip') + const urlValue: unknown = store?.get('url') + + const userId = + typeof userIdValue === 'number' ? userIdValue : undefined + const userEmail = + typeof userEmailValue === 'string' ? userEmailValue : undefined + const ipAddress = typeof ipValue === 'string' ? ipValue : undefined + const url = typeof urlValue === 'string' ? urlValue : undefined + + return client.$transaction(async (tx) => { + const result = await query(args) + + await tx.auditLog.create({ + data: { + action: 'UPDATE', + entity: model, + entityId: JSON.stringify(args.where), + // oldData, + newData: result, + userId, + userEmail, + ipAddress, + url, + }, + }) + + return result + }) + }, + }, + }, + }) +} + +export type AuditLogPrismaClient = ReturnType diff --git a/src/infra/prisma/prisma.service.ts b/src/infra/prisma/prisma.service.ts index 7bde6a7..78d3210 100644 --- a/src/infra/prisma/prisma.service.ts +++ b/src/infra/prisma/prisma.service.ts @@ -2,16 +2,28 @@ import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { PrismaPg } from '@prisma/adapter-pg' import { PrismaClient } from 'generated/prisma/client' +import { AlsService } from '../als/als.service' +import { AuditLogPrismaClient, auditLogExtension } from './prisma.extension' @Injectable() export class PrismaService extends PrismaClient { - constructor(config: ConfigService) { + private _audit?: AuditLogPrismaClient + + constructor( + config: ConfigService, + private readonly als: AlsService, + ) { const adapter = new PrismaPg({ connectionString: config.get('DATABASE_URL'), }) super({ adapter }) } + get withAudit(): AuditLogPrismaClient { + if (!this._audit) this._audit = auditLogExtension(this, this.als) + return this._audit + } + cleanDb() { return this.$transaction([ this.refreshToken.deleteMany(), diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 61ee0d9..c86773a 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -23,6 +23,7 @@ import { UserService } from './user.service' @ApiTags('Users') @ApiBearerAuth() +// @UseInterceptors(AuditInterceptor) @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @@ -66,6 +67,7 @@ export class UserController { return this.userService.reactivateUser(reactivateUserDto) } + // #remove this route (hard-delete), instead create user block and unblock routes (allow only super_admin/admin) @Delete('hard-delete') hardDeleteUser( @User('email') email: string, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 8b0ab60..616306d 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -77,7 +77,7 @@ export class UserService { async updateUser(userId: number, updateUserDto: UpdateUserDto) { try { - return await this.prisma.user.update({ + return await this.prisma.withAudit.user.update({ data: updateUserDto, where: { id: userId }, omit: { password: true }, From b0b0904476793a57635686e923e7ed8a7d54570a Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Thu, 30 Apr 2026 17:54:01 +0530 Subject: [PATCH 2/8] feat: add oldData in auditlog --- src/infra/prisma/prisma.extension.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/infra/prisma/prisma.extension.ts b/src/infra/prisma/prisma.extension.ts index 107dc1c..b7d8be5 100644 --- a/src/infra/prisma/prisma.extension.ts +++ b/src/infra/prisma/prisma.extension.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from 'generated/prisma/client' +import { PrismaClient, type Prisma } from 'generated/prisma/client' import { AlsService } from '../als/als.service' export const auditLogExtension = (client: PrismaClient, als: AlsService) => { @@ -8,10 +8,6 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { async update({ model, args, query }) { if (model === 'AuditLog') return query(args) - // const oldData = await (client as any)[model].findUnique({ - // where: args.where, - // }) - // Get the data from the ALS pocket! const store = als.getStore() const userIdValue: unknown = store?.get('userId') @@ -27,6 +23,15 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { const url = typeof urlValue === 'string' ? urlValue : undefined return client.$transaction(async (tx) => { + const txModels = tx as unknown as Record< + string, + { findUnique?: (params: { where: unknown }) => Promise } + > + + const oldData = await txModels[model]?.findUnique?.({ + where: args.where, + }) + const result = await query(args) await tx.auditLog.create({ @@ -34,7 +39,10 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { action: 'UPDATE', entity: model, entityId: JSON.stringify(args.where), - // oldData, + oldData: oldData as + | Prisma.InputJsonValue + | Prisma.NullableJsonNullValueInput + | undefined, newData: result, userId, userEmail, From a978e57ba5c39e3c8911508d5da3b8fa3dbe0652 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Fri, 1 May 2026 11:52:14 +0530 Subject: [PATCH 3/8] feat: modify prismaExtention Add req_method in auditLog --- .../migration.sql | 11 +++++ prisma/schema.prisma | 3 +- .../middleware/request-context.middleware.ts | 7 +-- src/infra/prisma/prisma.extension.ts | 47 +++++++++++-------- src/user/user.service.ts | 2 +- 5 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/20260501054238_modify_audit_log_model/migration.sql diff --git a/prisma/migrations/20260501054238_modify_audit_log_model/migration.sql b/prisma/migrations/20260501054238_modify_audit_log_model/migration.sql new file mode 100644 index 0000000..87c6a1f --- /dev/null +++ b/prisma/migrations/20260501054238_modify_audit_log_model/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `entity` on the `audit_logs` table. All the data in the column will be lost. + - You are about to drop the column `entity_id` on the `audit_logs` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "audit_logs" DROP COLUMN "entity", +DROP COLUMN "entity_id", +ADD COLUMN "method" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1ea2c9..7b9a367 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,11 +54,10 @@ model AuditLog { createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() // action String // e.g., "CREATE", "UPDATE", "DELETE" - entity String // e.g., "User", "Product" - entityId String @map("entity_id") oldData Json? @map("old_data") newData Json? @map("new_data") // + method String? // api request method url String? userId Int? @map("user_id") // ID of the user who performed the action userEmail String? @map("user_email") // Email of the user who performed the action diff --git a/src/common/middleware/request-context.middleware.ts b/src/common/middleware/request-context.middleware.ts index ffc8da6..af1c177 100644 --- a/src/common/middleware/request-context.middleware.ts +++ b/src/common/middleware/request-context.middleware.ts @@ -6,14 +6,11 @@ import { AlsService } from 'src/infra/als/als.service' export class RequestContextMiddleware implements NestMiddleware { constructor(private readonly als: AlsService) {} - use(req: Request, res: Response, next: NextFunction) { - // const mutationMethods = ['POST', 'PUT', 'PATCH', 'DELETE'] - // If it's a GET or HEAD request, just skip the ALS logic - // if (!mutationMethods.includes(req.method)) return next() - + use(req: Request, _res: Response, next: NextFunction) { const store: Map = new Map() store.set('ip', req.ip) store.set('url', req.url) + store.set('method', req.method) this.als.run(store, () => next()) } diff --git a/src/infra/prisma/prisma.extension.ts b/src/infra/prisma/prisma.extension.ts index b7d8be5..6611208 100644 --- a/src/infra/prisma/prisma.extension.ts +++ b/src/infra/prisma/prisma.extension.ts @@ -1,6 +1,17 @@ import { PrismaClient, type Prisma } from 'generated/prisma/client' import { AlsService } from '../als/als.service' +type PrismaJsonValue = + | Prisma.InputJsonValue + | Prisma.NullableJsonNullValueInput + | undefined + +const action = { + CREATE: 'CREATE', + UPDATA: 'UPDATE', + DELETE: 'DELETE', +} + export const auditLogExtension = (client: PrismaClient, als: AlsService) => { return client.$extends({ query: { @@ -8,19 +19,15 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { async update({ model, args, query }) { if (model === 'AuditLog') return query(args) - // Get the data from the ALS pocket! + // Get the data from the ALS pocket const store = als.getStore() - const userIdValue: unknown = store?.get('userId') - const userEmailValue: unknown = store?.get('userEmail') - const ipValue: unknown = store?.get('ip') - const urlValue: unknown = store?.get('url') - - const userId = - typeof userIdValue === 'number' ? userIdValue : undefined - const userEmail = - typeof userEmailValue === 'string' ? userEmailValue : undefined - const ipAddress = typeof ipValue === 'string' ? ipValue : undefined - const url = typeof urlValue === 'string' ? urlValue : undefined + const userId = store?.get('userId') as unknown as number | undefined + const userEmail = store?.get('userEmail') as unknown as + | string + | undefined + const ipAddress = store?.get('ip') as unknown as string | undefined + const url = store?.get('url') as unknown as string | undefined + const method = store?.get('method') as unknown as string | undefined return client.$transaction(async (tx) => { const txModels = tx as unknown as Record< @@ -34,20 +41,20 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { const result = await query(args) + const newData = await txModels[model]?.findUnique?.({ + where: args.where, + }) + await tx.auditLog.create({ data: { - action: 'UPDATE', - entity: model, - entityId: JSON.stringify(args.where), - oldData: oldData as - | Prisma.InputJsonValue - | Prisma.NullableJsonNullValueInput - | undefined, - newData: result, userId, userEmail, ipAddress, url, + method, + action: `${model.toUpperCase()}_${action.UPDATA}`, + oldData: oldData as PrismaJsonValue, + newData: newData as PrismaJsonValue, }, }) diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 616306d..bf29307 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -171,7 +171,7 @@ export class UserService { `User role is '${changeUserRoleDto.role}' already`, ) // change user role - const updatedUser = await this.prisma.user.update({ + const updatedUser = await this.prisma.withAudit.user.update({ where: { id }, data: { role: changeUserRoleDto.role, From 84798700d256d848f8cdc617261a94afa9e52445 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Fri, 1 May 2026 12:33:48 +0530 Subject: [PATCH 4/8] feat: add prismaExtention for create method --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/infra/prisma/audit.helper.ts | 13 +++++ src/infra/prisma/prisma.extension.ts | 55 +++++++++++++------ src/user/user.service.ts | 2 +- 5 files changed, 56 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20260501063555_add_entity_in_auditlog/migration.sql create mode 100644 src/infra/prisma/audit.helper.ts diff --git a/prisma/migrations/20260501063555_add_entity_in_auditlog/migration.sql b/prisma/migrations/20260501063555_add_entity_in_auditlog/migration.sql new file mode 100644 index 0000000..b55fa56 --- /dev/null +++ b/prisma/migrations/20260501063555_add_entity_in_auditlog/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "audit_logs" ADD COLUMN "entity" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7b9a367..8aac4c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,6 +54,7 @@ model AuditLog { createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() // action String // e.g., "CREATE", "UPDATE", "DELETE" + entity String? // e.g., "User", "Product" oldData Json? @map("old_data") newData Json? @map("new_data") // diff --git a/src/infra/prisma/audit.helper.ts b/src/infra/prisma/audit.helper.ts new file mode 100644 index 0000000..cb30874 --- /dev/null +++ b/src/infra/prisma/audit.helper.ts @@ -0,0 +1,13 @@ +import { AlsService } from '../als/als.service' + +export function getAuditContext(als: AlsService) { + const store = als.getStore() + + return { + userId: store?.get('userId') as number | undefined, + userEmail: store?.get('userEmail') as string | undefined, + ipAddress: store?.get('ip') as string | undefined, + url: store?.get('url') as string | undefined, + method: store?.get('method') as string | undefined, + } +} diff --git a/src/infra/prisma/prisma.extension.ts b/src/infra/prisma/prisma.extension.ts index 6611208..5a9ca0c 100644 --- a/src/infra/prisma/prisma.extension.ts +++ b/src/infra/prisma/prisma.extension.ts @@ -1,5 +1,6 @@ import { PrismaClient, type Prisma } from 'generated/prisma/client' import { AlsService } from '../als/als.service' +import { getAuditContext } from './audit.helper' type PrismaJsonValue = | Prisma.InputJsonValue @@ -16,18 +17,44 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { return client.$extends({ query: { $allModels: { + // create + async create({ model, args, query }) { + if (model === 'AuditLog') return query(args) + + // Get the data from the ALS pocket + const context = getAuditContext(als) + + return client.$transaction(async (tx) => { + const txModels = tx as unknown as Record< + string, + { findUnique?: (params: { where: unknown }) => Promise } + > + + const result = await query(args) + + const newData = await txModels[model]?.findUnique?.({ + where: { id: result.id }, + }) + + await tx.auditLog.create({ + data: { + ...context, + action: action.CREATE, + entity: model, + oldData: undefined, + newData: newData as PrismaJsonValue, + }, + }) + + return result + }) + }, + // update async update({ model, args, query }) { if (model === 'AuditLog') return query(args) // Get the data from the ALS pocket - const store = als.getStore() - const userId = store?.get('userId') as unknown as number | undefined - const userEmail = store?.get('userEmail') as unknown as - | string - | undefined - const ipAddress = store?.get('ip') as unknown as string | undefined - const url = store?.get('url') as unknown as string | undefined - const method = store?.get('method') as unknown as string | undefined + const context = getAuditContext(als) return client.$transaction(async (tx) => { const txModels = tx as unknown as Record< @@ -42,17 +69,13 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { const result = await query(args) const newData = await txModels[model]?.findUnique?.({ - where: args.where, + where: { id: result.id }, }) - await tx.auditLog.create({ data: { - userId, - userEmail, - ipAddress, - url, - method, - action: `${model.toUpperCase()}_${action.UPDATA}`, + ...context, + action: action.UPDATA, + entity: model, oldData: oldData as PrismaJsonValue, newData: newData as PrismaJsonValue, }, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index bf29307..ceb1088 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -37,7 +37,7 @@ export class UserService { try { // generate the password hash const hashedPassword = await argon.hash(createUserDto.password) - const createdUser = await this.prisma.user.create({ + const createdUser = await this.prisma.withAudit.user.create({ data: { ...createUserDto, password: hashedPassword }, omit: { password: omitPassword }, }) From 82d86b752ac5a517dde97ebc20b53e6decbf3fc4 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Fri, 1 May 2026 12:46:34 +0530 Subject: [PATCH 5/8] feat: implemented withAudit prisma query fns for auditlog --- src/auth/auth.controller.ts | 4 ---- src/auth/auth.service.ts | 6 +++--- src/user/user.controller.ts | 1 - src/user/user.service.ts | 9 +++------ 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 6c03d77..c331c98 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -33,7 +33,6 @@ export class AuthController { ) {} @Public() - // @UseInterceptors(AuditInterceptor) @Post('signup') signup(@Body() createUserDto: CreateUserDto) { return this.authService.signup(createUserDto) @@ -75,7 +74,6 @@ export class AuthController { } @ApiBearerAuth() - // @UseInterceptors(AuditInterceptor) @Patch('change-email') changeEmail( @User('email') oldEmail: string, @@ -85,7 +83,6 @@ export class AuthController { } @ApiBearerAuth() - // @UseInterceptors(AuditInterceptor) @Patch('change-password') changePassword( @User('email') email: string, @@ -95,7 +92,6 @@ export class AuthController { } @Public() - // @UseInterceptors(AuditInterceptor) @Patch('forgot-password') forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { return this.authService.forgotPassword(forgotPasswordDto) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 7843292..0e56a56 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -75,7 +75,7 @@ export class AuthService { false, ) await this.otpService.verifyCode(oldEmail, dto.oldEmailVerifiedCode, true) - const user = await this.prisma.user.update({ + const user = await this.prisma.withAudit.user.update({ where: { email: oldEmail }, data: { email: dto.newEmail, @@ -106,7 +106,7 @@ export class AuthService { // generate the password hash const hashedNewPassword = await argon.hash(dto.newPassword) // update user password - await this.prisma.user.update({ + await this.prisma.withAudit.user.update({ where: { email }, data: { password: hashedNewPassword }, }) @@ -122,7 +122,7 @@ export class AuthService { // generate the password hash const hashedNewPassword = await argon.hash(dto.newPassword) // update user password - await this.prisma.user.update({ + await this.prisma.withAudit.user.update({ where: { email: dto.email }, data: { password: hashedNewPassword }, }) diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index c86773a..c5162c4 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -23,7 +23,6 @@ import { UserService } from './user.service' @ApiTags('Users') @ApiBearerAuth() -// @UseInterceptors(AuditInterceptor) @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index ceb1088..f0d72ce 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -94,7 +94,7 @@ export class UserService { async softDeleteUser(email: string, dto: DeleteUserDto) { try { await this.otpService.verifyCode(email, dto.emailVerifiedCode, true) - const deletedUser = await this.prisma.user.update({ + const deletedUser = await this.prisma.withAudit.user.update({ where: { email }, data: { deletedAt: new Date() }, omit: { password: true }, @@ -116,14 +116,11 @@ export class UserService { } async reactivateUser(dto: ReactivateUserDto) { - const foundUser = await this.prisma.user.findUnique({ - where: { email: dto.email }, - omit: { password: true }, - }) + const foundUser = await this.findUserByEmail(dto.email) if (!foundUser) throw new NotFoundException('User not found') if (!foundUser.deletedAt) throw new ConflictException('User already active') await this.otpService.verifyCode(dto.email, dto.emailVerifiedCode) - const reactivatedUser = await this.prisma.user.update({ + const reactivatedUser = await this.prisma.withAudit.user.update({ where: { email: dto.email }, data: { deletedAt: null }, omit: { password: true }, From eef57e1305721c9d72c4947d62bc259a4a659144 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Fri, 1 May 2026 14:34:15 +0530 Subject: [PATCH 6/8] test: add tests for prismaExtension changes --- src/auth/auth.service.spec.ts | 21 ++++++++++++------- src/auth/guard/auth.guard.spec.ts | 6 ++++++ src/user/user.service.spec.ts | 34 ++++++++++++++++++++----------- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index b0f78e9..4949f50 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -24,7 +24,11 @@ describe('AuthService', () => { const mockPrisma = { user: { findUnique: jest.fn(), - update: jest.fn(), + }, + withAudit: { + user: { + update: jest.fn(), + }, }, refreshToken: { deleteMany: jest.fn(), @@ -137,7 +141,10 @@ describe('AuthService', () => { describe('changeEmail', () => { it('should verify codes, update user and revoke all refresh tokens', async () => { mockOtpService.verifyCode.mockResolvedValue(true) - mockPrisma.user.update.mockResolvedValue({ id: 1, email: 'new@t.com' }) + mockPrisma.withAudit.user.update.mockResolvedValue({ + id: 1, + email: 'new@t.com', + }) mockPrisma.refreshToken.deleteMany.mockResolvedValue({}) mockTokenService.generateToken.mockResolvedValue({ accessToken: 'a' }) @@ -158,7 +165,7 @@ describe('AuthService', () => { 'c2', true, ) - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'old@t.com' }, data: { email: 'new@t.com', tokenVersion: { increment: 1 } }, }) @@ -183,7 +190,7 @@ describe('AuthService', () => { }) ;(argon.verify as jest.Mock).mockResolvedValue(true) ;(argon.hash as jest.Mock).mockResolvedValue('newHashed') - mockPrisma.user.update.mockResolvedValue({}) + mockPrisma.withAudit.user.update.mockResolvedValue({}) const dto = { oldPassword: 'o', @@ -203,7 +210,7 @@ describe('AuthService', () => { ) expect(argon.verify).toHaveBeenCalledWith('oldHashed', 'o') expect(argon.hash).toHaveBeenCalledWith('n') - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'test@t.com' }, data: { password: 'newHashed' }, }) @@ -233,7 +240,7 @@ describe('AuthService', () => { mockUsersService.findUserByEmail.mockResolvedValue({ id: 1 }) mockOtpService.verifyCode.mockResolvedValue(true) ;(argon.hash as jest.Mock).mockResolvedValue('newHashed') - mockPrisma.user.update.mockResolvedValue({}) + mockPrisma.withAudit.user.update.mockResolvedValue({}) mockTokenService.generateToken.mockResolvedValue({ accessToken: 'a' }) const dto = { @@ -251,7 +258,7 @@ describe('AuthService', () => { 'code', ) expect(argon.hash).toHaveBeenCalledWith('n') - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'test@t.com' }, data: { password: 'newHashed' }, }) diff --git a/src/auth/guard/auth.guard.spec.ts b/src/auth/guard/auth.guard.spec.ts index 44163ca..340718d 100644 --- a/src/auth/guard/auth.guard.spec.ts +++ b/src/auth/guard/auth.guard.spec.ts @@ -10,6 +10,7 @@ import { Test, TestingModule } from '@nestjs/testing' import { Request } from 'express' import authConfig from 'src/auth/config/auth.config' import { IS_PUBLIC_KEY } from 'src/auth/decorator' +import { AlsService } from 'src/infra/als/als.service' import { PrismaService } from 'src/infra/prisma/prisma.service' import { AuthGuard, REQUEST_USER_KEY } from './auth.guard' @@ -31,6 +32,9 @@ describe('AuthGuard', () => { } const mockAuthConfig = { secret: 'test-secret' } + const mockAlsService = { + getStore: jest.fn(), + } beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -40,6 +44,7 @@ describe('AuthGuard', () => { { provide: JwtService, useValue: mockJwtService }, { provide: PrismaService, useValue: mockPrisma }, { provide: authConfig.KEY, useValue: mockAuthConfig }, + { provide: AlsService, useValue: mockAlsService }, ], }).compile() @@ -48,6 +53,7 @@ describe('AuthGuard', () => { afterEach(() => { jest.clearAllMocks() + mockAlsService.getStore.mockReturnValue(undefined) }) const mockContext = (headers: Record = {}) => { diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 03353ac..53f6172 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -21,10 +21,14 @@ describe('UserService', () => { user: { findMany: jest.fn(), findUnique: jest.fn(), - create: jest.fn(), - update: jest.fn(), delete: jest.fn(), }, + withAudit: { + user: { + create: jest.fn(), + update: jest.fn(), + }, + }, } const mockOtpService = { @@ -71,7 +75,10 @@ describe('UserService', () => { describe('createUser', () => { it('should create and return user without password', async () => { ;(argon.hash as jest.Mock).mockResolvedValue('hashed_password') - mockPrisma.user.create.mockResolvedValue({ id: 1, email: 'test@t.com' }) + mockPrisma.withAudit.user.create.mockResolvedValue({ + id: 1, + email: 'test@t.com', + }) const result = await service.createUser({ email: 'test@t.com', password: 'password', @@ -79,7 +86,7 @@ describe('UserService', () => { lastName: 't', }) expect(result).toEqual({ id: 1, email: 'test@t.com' }) - expect(mockPrisma.user.create).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.create).toHaveBeenCalledWith({ data: { email: 'test@t.com', password: 'hashed_password', @@ -96,7 +103,7 @@ describe('UserService', () => { code: 'P2002', clientVersion: '1', }) - mockPrisma.user.create.mockRejectedValue(err) + mockPrisma.withAudit.user.create.mockRejectedValue(err) await expect( service.createUser({ email: 'test@t.com', @@ -133,7 +140,10 @@ describe('UserService', () => { describe('updateUser', () => { it('should return updated user', async () => { - mockPrisma.user.update.mockResolvedValue({ id: 1, firstName: 'A' }) + mockPrisma.withAudit.user.update.mockResolvedValue({ + id: 1, + firstName: 'A', + }) await expect(service.updateUser(1, { firstName: 'A' })).resolves.toEqual({ id: 1, firstName: 'A', @@ -144,7 +154,7 @@ describe('UserService', () => { describe('softDeleteUser', () => { it('should soft delete user and revoke token', async () => { mockOtpService.verifyCode.mockResolvedValue(true) - mockPrisma.user.update.mockResolvedValue({ + mockPrisma.withAudit.user.update.mockResolvedValue({ id: 1, deletedAt: new Date(), }) @@ -167,7 +177,7 @@ describe('UserService', () => { code: 'P2025', clientVersion: '1', }) - mockPrisma.user.update.mockRejectedValue(err) + mockPrisma.withAudit.user.update.mockRejectedValue(err) await expect( service.softDeleteUser('t@t.com', { emailVerifiedCode: 'code' }), @@ -199,7 +209,7 @@ describe('UserService', () => { deletedAt: new Date(), }) mockOtpService.verifyCode.mockResolvedValue(true) - mockPrisma.user.update.mockResolvedValue({ + mockPrisma.withAudit.user.update.mockResolvedValue({ id: 1, email: 'deleted@t.com', deletedAt: null, @@ -219,7 +229,7 @@ describe('UserService', () => { 'deleted@t.com', 'verified-code', ) - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { email: 'deleted@t.com' }, data: { deletedAt: null }, omit: { password: true }, @@ -290,7 +300,7 @@ describe('UserService', () => { it('should change role, increment tokenVersion and return message', async () => { mockPrisma.user.findUnique.mockResolvedValue({ role: Role.USER }) - mockPrisma.user.update.mockResolvedValue({ + mockPrisma.withAudit.user.update.mockResolvedValue({ id: 1, role: Role.ADMIN, email: 't@t.com', @@ -303,7 +313,7 @@ describe('UserService', () => { email: 't@t.com', message: "User role changed from 'USER' to 'ADMIN'", }) - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(mockPrisma.withAudit.user.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { role: Role.ADMIN, tokenVersion: { increment: 1 } }, select: { id: true, role: true, email: true }, From b9084ca12f9d0d1c3c29547eb050df7184d42980 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Sat, 2 May 2026 00:28:30 +0530 Subject: [PATCH 7/8] feat: modify audit_logs table as partition table --- .../migration.sql | 19 +++++++++++++++++++ .../migration.sql | 14 ++++++++++++++ prisma/schema.prisma | 7 +++++-- 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260501175545_audit_logs_partition/migration.sql create mode 100644 prisma/migrations/20260501185440_index_audit_logs/migration.sql diff --git a/prisma/migrations/20260501175545_audit_logs_partition/migration.sql b/prisma/migrations/20260501175545_audit_logs_partition/migration.sql new file mode 100644 index 0000000..3b876de --- /dev/null +++ b/prisma/migrations/20260501175545_audit_logs_partition/migration.sql @@ -0,0 +1,19 @@ +-- -- DropTable +DROP TABLE IF EXISTS audit_logs; + +-- CreatePartitionTable +CREATE TABLE audit_logs ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" TEXT NOT NULL, + "entity" TEXT, + "old_data" JSONB, + "new_data" JSONB, + "method" TEXT, + "url" TEXT, + "user_id" INTEGER, + "user_email" TEXT, + "ip_address" TEXT, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id", "created_at") +) PARTITION BY RANGE ("created_at"); \ No newline at end of file diff --git a/prisma/migrations/20260501185440_index_audit_logs/migration.sql b/prisma/migrations/20260501185440_index_audit_logs/migration.sql new file mode 100644 index 0000000..7482456 --- /dev/null +++ b/prisma/migrations/20260501185440_index_audit_logs/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `entity` on table `audit_logs` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "audit_logs" ALTER COLUMN "entity" SET NOT NULL; + +-- CreateIndex +CREATE INDEX "idx_audit_logs_user_id" ON "audit_logs"("user_id"); + +-- CreateIndex +CREATE INDEX "idx_audit_logs_user_email" ON "audit_logs"("user_email"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8aac4c8..320af93 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,11 +50,11 @@ model RefreshToken { } model AuditLog { - id String @id @default(uuid()) + id String @default(uuid()) createdAt DateTime @default(now()) @map("created_at") @db.Timestamp() // action String // e.g., "CREATE", "UPDATE", "DELETE" - entity String? // e.g., "User", "Product" + entity String // e.g., "User", "Product" oldData Json? @map("old_data") newData Json? @map("new_data") // @@ -64,5 +64,8 @@ model AuditLog { userEmail String? @map("user_email") // Email of the user who performed the action ipAddress String? @map("ip_address") + @@id([id, createdAt]) + @@index([userId], map: "idx_audit_logs_user_id") + @@index([userEmail], map: "idx_audit_logs_user_email") @@map("audit_logs") } From 443d3f28da941f6ff89d8595ff3aaebeea4efc44 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Sat, 2 May 2026 17:06:21 +0530 Subject: [PATCH 8/8] feat: add pg_cron jobs for audit_logs Add schedules to create next month partition and drop old partition --- Dockerfile.db | 62 ++++++++++++++++++- docker-compose.dev.yml | 7 ++- .../migration.sql | 24 +++++++ 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20260502111657_audit_log_initial_partition/migration.sql diff --git a/Dockerfile.db b/Dockerfile.db index 806a5e2..7a128d6 100644 --- a/Dockerfile.db +++ b/Dockerfile.db @@ -6,9 +6,65 @@ RUN apt-get update && \ # Initialize pg_cron and schedule the job in DB RUN cat < /docker-entrypoint-initdb.d/001-pg-cron.sql CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- ========================= +-- Schedule the cleanup for refresh_tokens (every sunday midnight) +-- ========================= SELECT cron.schedule( - 'refresh-token-cleanup', - '0 0 * * *', - \$\$DELETE FROM refresh_tokens WHERE expires_at < NOW();\$\$ + 'refresh-token-cleanup', + '0 0 * * 0', + \$\$ + DELETE FROM refresh_tokens WHERE expires_at < NOW(); + \$\$ +); + +-- ========================= +-- Schedule the next month Partition Creation for audit_logs (every 1 month) +-- ========================= +SELECT cron.schedule( + 'create-audit-partition', + '0 0 1 * *', + \$job\$ + DO \$do\$ + DECLARE + start_date DATE := date_trunc('month', NOW()); + next_month DATE := start_date + INTERVAL '1 month'; + BEGIN + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_logs + FOR VALUES FROM (%L) TO (%L);', + 'audit_logs_' || to_char(next_month, 'YYYY_MM'), + next_month, + next_month + INTERVAL '1 month' + ); + END + \$do\$; + \$job\$ +); + +-- ========================= +-- Schedule the Partition Drop for audit_logs (every 1 month, 3 month retention) +-- ========================= +SELECT cron.schedule( + 'drop-old-audit-partitions', + '0 1 1 * *', + \$job\$ + DO \$do\$ + DECLARE + cutoff DATE := date_trunc('month', NOW()) - INTERVAL '3 months'; + partition_name TEXT; + BEGIN + FOR partition_name IN + SELECT tablename + FROM pg_tables + WHERE tablename LIKE 'audit_logs_%' + LOOP + IF partition_name < 'audit_logs_' || to_char(cutoff, 'YYYY_MM') THEN + EXECUTE format('DROP TABLE IF EXISTS %I;', partition_name); + END IF; + END LOOP; + END + \$do\$; + \$job\$ ); EOF \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d76c697..c279269 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -27,8 +27,13 @@ services: networks: - dev_infra_net dev-db: - image: postgres:17 + build: + context: . + dockerfile: Dockerfile.db container_name: dev_db + command: > + postgres -c shared_preload_libraries=pg_cron + -c cron.database_name=auth_system ports: - '5431:5432' env_file: diff --git a/prisma/migrations/20260502111657_audit_log_initial_partition/migration.sql b/prisma/migrations/20260502111657_audit_log_initial_partition/migration.sql new file mode 100644 index 0000000..eeb626e --- /dev/null +++ b/prisma/migrations/20260502111657_audit_log_initial_partition/migration.sql @@ -0,0 +1,24 @@ +-- Create initial Partitions for audit_logs +DO $$ +DECLARE + start_date DATE := date_trunc('month', NOW()); + next_month DATE := start_date + INTERVAL '1 month'; +BEGIN + -- current month + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_logs + FOR VALUES FROM (%L) TO (%L);', + 'audit_logs_' || to_char(start_date, 'YYYY_MM'), + start_date, + next_month + ); + -- next month + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_logs + FOR VALUES FROM (%L) TO (%L);', + 'audit_logs_' || to_char(next_month, 'YYYY_MM'), + next_month, + next_month + INTERVAL '1 month' + ); +END +$$; \ No newline at end of file