From c380954e0ef4a0776de1c8eb5b932a40c32765d2 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Thu, 7 May 2026 15:30:59 +0530 Subject: [PATCH 1/2] feat: add audit-log module --- .../migration.sql | 5 ++ prisma/schema.prisma | 2 + src/app.module.ts | 2 + src/audit-log/audit-log.controller.ts | 19 ++++++ src/audit-log/audit-log.module.ts | 11 +++ src/audit-log/audit-log.service.ts | 67 +++++++++++++++++++ src/audit-log/constant/audit-log-type.ts | 5 ++ src/audit-log/constant/index.ts | 1 + src/audit-log/dto/getAuditLogs.dto.ts | 34 ++++++++++ src/audit-log/dto/index.ts | 1 + src/common/filter/all-exceptions.filter.ts | 7 +- .../prisma/extension/prisma.extension.ts | 20 ++++-- src/infra/prisma/util/audit.util.ts | 13 ---- src/infra/prisma/util/index.ts | 1 - 14 files changed, 161 insertions(+), 27 deletions(-) create mode 100644 prisma/migrations/20260507061916_add_more_index_audit_logs/migration.sql create mode 100644 src/audit-log/audit-log.controller.ts create mode 100644 src/audit-log/audit-log.module.ts create mode 100644 src/audit-log/audit-log.service.ts create mode 100644 src/audit-log/constant/audit-log-type.ts create mode 100644 src/audit-log/constant/index.ts create mode 100644 src/audit-log/dto/getAuditLogs.dto.ts create mode 100644 src/audit-log/dto/index.ts delete mode 100644 src/infra/prisma/util/audit.util.ts delete mode 100644 src/infra/prisma/util/index.ts diff --git a/prisma/migrations/20260507061916_add_more_index_audit_logs/migration.sql b/prisma/migrations/20260507061916_add_more_index_audit_logs/migration.sql new file mode 100644 index 0000000..1574bfb --- /dev/null +++ b/prisma/migrations/20260507061916_add_more_index_audit_logs/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "idx_audit_logs_created_at_desc" ON "audit_logs"("created_at" DESC); + +-- CreateIndex +CREATE INDEX "idx_audit_logs_type_created_at" ON "audit_logs"("type", "created_at" DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6de79f9..c0b2418 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,5 +66,7 @@ model AuditLog { @@id([id, createdAt]) @@index([userId], map: "idx_audit_logs_user_id") @@index([userEmail], map: "idx_audit_logs_user_email") + @@index([createdAt(sort: Desc)], map: "idx_audit_logs_created_at_desc") + @@index([type, createdAt(sort: Desc)], map: "idx_audit_logs_type_created_at") @@map("audit_logs") } diff --git a/src/app.module.ts b/src/app.module.ts index 3c48508..5116f2b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { ScheduleModule } from '@nestjs/schedule' import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler' import { AppController } from './app.controller' import { AppService } from './app.service' +import { AuditLogModule } from './audit-log/audit-log.module' import { AuthInfrastructureModule } from './auth/auth-infrastructure.module' import { AuthModule } from './auth/auth.module' import { AllExceptionsFilter } from './common/filter' @@ -47,6 +48,7 @@ import { UserModule } from './user/user.module' }), ScheduleModule.forRoot(), AlsModule, + AuditLogModule, ], controllers: [AppController], providers: [ diff --git a/src/audit-log/audit-log.controller.ts b/src/audit-log/audit-log.controller.ts new file mode 100644 index 0000000..d55454d --- /dev/null +++ b/src/audit-log/audit-log.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Query } from '@nestjs/common' +import { Role } from 'generated/prisma/enums' +import { Roles, User } from 'src/auth/decorator' +import { AuditLogService } from './audit-log.service' +import { GetAuditLogsQueryDto } from './dto' + +@Controller('audit-logs') +export class AuditLogController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Roles(Role.SUPER_ADMIN, Role.ADMIN) + @Get() + getAuditLogs( + @User('role') userRole: string, + @Query() getAuditLogsQueryDto: GetAuditLogsQueryDto, + ) { + return this.auditLogService.findAllAuditLogs(userRole, getAuditLogsQueryDto) + } +} diff --git a/src/audit-log/audit-log.module.ts b/src/audit-log/audit-log.module.ts new file mode 100644 index 0000000..3e4999d --- /dev/null +++ b/src/audit-log/audit-log.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { PaginationModule } from 'src/common/pagination/pagination.module' +import { AuditLogController } from './audit-log.controller' +import { AuditLogService } from './audit-log.service' + +@Module({ + imports: [PaginationModule], + controllers: [AuditLogController], + providers: [AuditLogService], +}) +export class AuditLogModule {} diff --git a/src/audit-log/audit-log.service.ts b/src/audit-log/audit-log.service.ts new file mode 100644 index 0000000..8019667 --- /dev/null +++ b/src/audit-log/audit-log.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common' +import { PaginationProvider } from 'src/common/pagination/pagination.provider' +import { PrismaService } from 'src/infra/prisma/prisma.service' +import { GetAuditLogsQueryDto } from './dto' + +@Injectable() +export class AuditLogService { + constructor( + private readonly prisma: PrismaService, + private readonly paginationProvider: PaginationProvider, + ) {} + + public async findAllAuditLogs( + userRole: string, + getAuditLogsQueryDto: GetAuditLogsQueryDto, + ) { + // order query + const orderByField = getAuditLogsQueryDto.orderBy + const orderDirection = getAuditLogsQueryDto.order === 'asc' ? 'asc' : 'desc' + // data query + const { monthStart, monthEnd } = this.getDateFallbacks() + const fromDate = new Date(getAuditLogsQueryDto.from ?? monthStart) + const toDate = new Date(getAuditLogsQueryDto.to ?? monthEnd) + toDate.setDate(toDate.getDate() + 1) + + return await this.paginationProvider.paginateQuery( + this.prisma.auditLog, + getAuditLogsQueryDto, + { + where: { + createdAt: { + gte: fromDate, + lt: toDate, + }, + type: { + not: userRole === 'ADMIN' ? 'ERROR' : undefined, + contains: getAuditLogsQueryDto.type ?? undefined, + }, + ...(getAuditLogsQueryDto.userId && { + userId: getAuditLogsQueryDto.userId, + }), + ...(getAuditLogsQueryDto.userEmail && { + userEmail: { + equals: getAuditLogsQueryDto.userEmail, + mode: 'insensitive', + }, + }), + }, + orderBy: { + [orderByField!]: orderByField ? orderDirection : undefined, + }, + }, + ) + } + + private getDateFallbacks() { + const now = new Date() + const monthStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + ) + const monthEnd = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), + ) + + return { monthStart, monthEnd } + } +} diff --git a/src/audit-log/constant/audit-log-type.ts b/src/audit-log/constant/audit-log-type.ts new file mode 100644 index 0000000..940c29c --- /dev/null +++ b/src/audit-log/constant/audit-log-type.ts @@ -0,0 +1,5 @@ +export const auditLogType = { + CREATE: 'CREATE', + UPDATA: 'UPDATE', + ERROR: 'ERROR', +} diff --git a/src/audit-log/constant/index.ts b/src/audit-log/constant/index.ts new file mode 100644 index 0000000..33193fb --- /dev/null +++ b/src/audit-log/constant/index.ts @@ -0,0 +1 @@ +export * from './audit-log-type' diff --git a/src/audit-log/dto/getAuditLogs.dto.ts b/src/audit-log/dto/getAuditLogs.dto.ts new file mode 100644 index 0000000..28f6c66 --- /dev/null +++ b/src/audit-log/dto/getAuditLogs.dto.ts @@ -0,0 +1,34 @@ +import { Type } from 'class-transformer' +import { IsDateString, IsEmail, IsIn, IsInt, IsOptional } from 'class-validator' +import { PaginationQueryDto } from 'src/common/pagination/dto' + +export class GetAuditLogsQueryDto extends PaginationQueryDto { + @IsOptional() + @IsIn(['createdAt', 'type', 'requestUrl', 'requestMethod']) + orderBy?: string + + @IsOptional() + @IsIn(['asc', 'desc']) + order?: 'asc' | 'desc' + + @IsOptional() + @IsDateString() + from?: string + + @IsOptional() + @IsDateString() + to?: string + + @IsOptional() + @IsIn(['CREATE', 'UPDATE', 'DELETE', 'ERROR']) + type?: string + + @Type(() => Number) + @IsOptional() + @IsInt() + userId?: string + + @IsOptional() + @IsEmail() + userEmail?: string +} diff --git a/src/audit-log/dto/index.ts b/src/audit-log/dto/index.ts new file mode 100644 index 0000000..1c5a802 --- /dev/null +++ b/src/audit-log/dto/index.ts @@ -0,0 +1 @@ +export * from './getAuditLogs.dto' diff --git a/src/common/filter/all-exceptions.filter.ts b/src/common/filter/all-exceptions.filter.ts index 28f3943..7cba712 100644 --- a/src/common/filter/all-exceptions.filter.ts +++ b/src/common/filter/all-exceptions.filter.ts @@ -6,15 +6,10 @@ import { HttpStatus, } from '@nestjs/common' import { Request, Response } from 'express' +import { auditLogType } from 'src/audit-log/constant' import { REQUEST_USER_KEY } from 'src/auth/guard' import { PrismaService } from 'src/infra/prisma/prisma.service' -const auditLogType = { - CREATE: 'CREATE', - UPDATA: 'UPDATE', - ERROR: 'ERROR', -} - @Catch() // Leaving this empty catches EVERYTHING export class AllExceptionsFilter implements ExceptionFilter { constructor(private readonly prisma: PrismaService) {} diff --git a/src/infra/prisma/extension/prisma.extension.ts b/src/infra/prisma/extension/prisma.extension.ts index 662d2d9..d78c1a3 100644 --- a/src/infra/prisma/extension/prisma.extension.ts +++ b/src/infra/prisma/extension/prisma.extension.ts @@ -1,12 +1,6 @@ import { PrismaClient, type Prisma } from 'generated/prisma/client' +import { auditLogType } from 'src/audit-log/constant' import { AlsService } from 'src/infra/als/als.service' -import { getAuditContext } from '../util' - -const auditLogType = { - CREATE: 'CREATE', - UPDATA: 'UPDATE', - ERROR: 'ERROR', -} export const auditLogExtension = (client: PrismaClient, als: AlsService) => { return client.$extends({ @@ -93,3 +87,15 @@ export const auditLogExtension = (client: PrismaClient, als: AlsService) => { } export type AuditLogPrismaClient = ReturnType + +function getAuditContext(als: AlsService) { + const store = als.getStore() + + return { + requestUrl: store?.get('url') as string | undefined, + requestMethod: store?.get('method') as string | undefined, + userId: store?.get('userId') as number | undefined, + userEmail: store?.get('userEmail') as string | undefined, + userIpAddress: store?.get('ip') as string | undefined, + } +} diff --git a/src/infra/prisma/util/audit.util.ts b/src/infra/prisma/util/audit.util.ts deleted file mode 100644 index efe8c65..0000000 --- a/src/infra/prisma/util/audit.util.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AlsService } from 'src/infra/als/als.service' - -export function getAuditContext(als: AlsService) { - const store = als.getStore() - - return { - requestUrl: store?.get('url') as string | undefined, - requestMethod: store?.get('method') as string | undefined, - userId: store?.get('userId') as number | undefined, - userEmail: store?.get('userEmail') as string | undefined, - userIpAddress: store?.get('ip') as string | undefined, - } -} diff --git a/src/infra/prisma/util/index.ts b/src/infra/prisma/util/index.ts deleted file mode 100644 index e1d3440..0000000 --- a/src/infra/prisma/util/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './audit.util' From 8d3fb8855a3e20460b2210723663e7032371cf6c Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Thu, 7 May 2026 15:55:15 +0530 Subject: [PATCH 2/2] test: add tests for audit-log module --- src/audit-log/audit-log.controller.spec.ts | 47 +++++++ src/audit-log/audit-log.service.spec.ts | 124 ++++++++++++++++ test/audit-log.e2e-spec.ts | 156 +++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 src/audit-log/audit-log.controller.spec.ts create mode 100644 src/audit-log/audit-log.service.spec.ts create mode 100644 test/audit-log.e2e-spec.ts diff --git a/src/audit-log/audit-log.controller.spec.ts b/src/audit-log/audit-log.controller.spec.ts new file mode 100644 index 0000000..2b681ee --- /dev/null +++ b/src/audit-log/audit-log.controller.spec.ts @@ -0,0 +1,47 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { AuditLogController } from './audit-log.controller' +import { AuditLogService } from './audit-log.service' +import { GetAuditLogsQueryDto } from './dto' + +describe('AuditLogController', () => { + let controller: AuditLogController + + const mockAuditLogService = { + findAllAuditLogs: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuditLogController], + providers: [ + { + provide: AuditLogService, + useValue: mockAuditLogService, + }, + ], + }).compile() + + controller = module.get(AuditLogController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('getAuditLogs', () => { + it('should call auditLogService.findAllAuditLogs with correct parameters', async () => { + const userRole = 'ADMIN' + const dto: GetAuditLogsQueryDto = { page: 1, limit: 10 } + + mockAuditLogService.findAllAuditLogs.mockResolvedValue('result' as any) + + const result = await controller.getAuditLogs(userRole, dto) + + expect(mockAuditLogService.findAllAuditLogs).toHaveBeenCalledWith( + userRole, + dto, + ) + expect(result).toBe('result') + }) + }) +}) diff --git a/src/audit-log/audit-log.service.spec.ts b/src/audit-log/audit-log.service.spec.ts new file mode 100644 index 0000000..2514d91 --- /dev/null +++ b/src/audit-log/audit-log.service.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing' +import type { AuditLog } from 'generated/prisma/client' +import { Paginated } from 'src/common/pagination/interfaces' +import { PaginationProvider } from 'src/common/pagination/pagination.provider' +import { PrismaService } from 'src/infra/prisma/prisma.service' +import { AuditLogService } from './audit-log.service' +import { GetAuditLogsQueryDto } from './dto' + +describe('AuditLogService', () => { + let service: AuditLogService + let prisma: PrismaService + let paginationProvider: PaginationProvider + + const mockPrismaService = { + auditLog: { + findMany: jest.fn(), + count: jest.fn(), + }, + } + + const mockPaginationProvider = { + paginateQuery: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditLogService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: PaginationProvider, + useValue: mockPaginationProvider, + }, + ], + }).compile() + + service = module.get(AuditLogService) + prisma = module.get(PrismaService) + paginationProvider = module.get(PaginationProvider) + jest.clearAllMocks() + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('findAllAuditLogs', () => { + it('should query logs correctly for ADMIN', async () => { + const dto: GetAuditLogsQueryDto = { + orderBy: 'createdAt', + order: 'desc', + } + + const paginateSpy = jest + .spyOn(paginationProvider, 'paginateQuery') + .mockResolvedValue({ + data: [], + meta: { + totalCount: 0, + itemsPerPage: 10, + totalPages: 0, + currentPage: 1, + }, + } as unknown as Paginated) + + await service.findAllAuditLogs('ADMIN', dto) + + expect(paginateSpy).toHaveBeenCalledWith( + prisma.auditLog, + dto, + expect.objectContaining({ + where: expect.objectContaining({ + type: { + not: 'ERROR', + contains: undefined, + }, + }) as Record, + orderBy: { createdAt: 'desc' }, + }), + ) + }) + + it('should query logs correctly for SUPER_ADMIN', async () => { + const dto: GetAuditLogsQueryDto = { + orderBy: 'type', + order: 'asc', + type: 'CREATE', + userId: '1', + } + + const paginateSpy = jest + .spyOn(paginationProvider, 'paginateQuery') + .mockResolvedValue({ + data: [], + meta: { + totalCount: 0, + itemsPerPage: 10, + totalPages: 0, + currentPage: 1, + }, + } as unknown as Paginated) + + await service.findAllAuditLogs('SUPER_ADMIN', dto) + + expect(paginateSpy).toHaveBeenCalledWith( + prisma.auditLog, + dto, + expect.objectContaining({ + where: expect.objectContaining({ + type: { + not: undefined, + contains: 'CREATE', + }, + userId: '1', + }) as Record, + orderBy: { type: 'asc' }, + }), + ) + }) + }) +}) diff --git a/test/audit-log.e2e-spec.ts b/test/audit-log.e2e-spec.ts new file mode 100644 index 0000000..c003d4d --- /dev/null +++ b/test/audit-log.e2e-spec.ts @@ -0,0 +1,156 @@ +import type { INestApplication } from '@nestjs/common' +import { type TestingModule, Test } from '@nestjs/testing' +import { Role } from 'generated/prisma/enums' +import { AppModule } from 'src/app.module' +import { PrismaService } from 'src/infra/prisma/prisma.service' +import request from 'supertest' +import { App } from 'supertest/types' +import { Paginated } from 'src/common/pagination/interfaces' + +describe('AuditLogController (e2e)', () => { + let app: INestApplication + let prisma: PrismaService + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile() + + app = moduleFixture.createNestApplication() + prisma = app.get(PrismaService) + await app.init() + }) + + afterEach(async () => { + await prisma.cleanDb() + await app.close() + }) + + const getTokens = async (email: string, role: Role = Role.USER) => { + const password = 'StrongPassword!123' + // 1. Get OTP + const otpRes: { body: { otp: string } } = await request(app.getHttpServer()) + .post('/auth/email-otp') + .send({ email }) + const otp = otpRes.body.otp + + // 2. Verify OTP + const verifyRes: { body: { verifiedCode: string } } = await request( + app.getHttpServer(), + ) + .post('/auth/verify-otp') + .send({ email, otp }) + const emailVerifiedCode = verifyRes.body.verifiedCode + + // 3. Signup + await request(app.getHttpServer()).post('/auth/signup').send({ + email, + password, + emailVerifiedCode, + firstName: 'Test', + lastName: 'User', + }) + + // 4. Update role if needed + if (role !== Role.USER) { + await prisma.user.update({ + where: { email }, + data: { role }, + }) + } + + // 5. Login + const loginRes: { body: { accessToken: string; refreshToken: string } } = + await request(app.getHttpServer()) + .post('/auth/login') + .send({ email, password }) + + return loginRes.body + } + + describe('GET /audit-logs', () => { + it('should return audit logs for ADMIN', async () => { + const { accessToken } = await getTokens('admin@example.com', Role.ADMIN) + + // Create a test audit log + await prisma.auditLog.create({ + data: { + type: 'CREATE', + userId: 1, + userEmail: 'admin@example.com', + requestMethod: 'GET', + requestUrl: '/test', + userIpAddress: '127.0.0.1', + }, + }) + + const res: { body: Paginated } = await request( + app.getHttpServer(), + ) + .get('/audit-logs') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + + expect(Array.isArray(res.body.data)).toBe(true) + expect(res.body.data.length).toBeGreaterThanOrEqual(1) + expect(res.body.meta).toBeDefined() + }) + + it('should return audit logs for SUPER_ADMIN', async () => { + const { accessToken } = await getTokens( + 'superadmin@example.com', + Role.SUPER_ADMIN, + ) + + const res: { body: Paginated } = await request( + app.getHttpServer(), + ) + .get('/audit-logs') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + + expect(Array.isArray(res.body.data)).toBe(true) + expect(res.body.meta).toBeDefined() + }) + + it('should filter audit logs by type', async () => { + const { accessToken } = await getTokens( + 'superadmin-filter@example.com', + Role.SUPER_ADMIN, + ) + + await prisma.auditLog.create({ + data: { + type: 'DELETE', + userId: 1, + userEmail: 'admin@example.com', + requestMethod: 'GET', + requestUrl: '/test', + userIpAddress: '127.0.0.1', + }, + }) + + const res: { body: Paginated } = await request( + app.getHttpServer(), + ) + .get('/audit-logs?type=DELETE') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + + expect(Array.isArray(res.body.data)).toBe(true) + if (res.body.data.length > 0) { + expect((res.body.data[0] as Record).type).toBe( + 'DELETE', + ) + } + }) + + it('should return 403 for regular USER', async () => { + const { accessToken } = await getTokens('user@example.com') + await request(app.getHttpServer()) + .get('/audit-logs') + .set('Authorization', `Bearer ${accessToken}`) + .expect(403) + }) + }) +})