diff --git a/src/modules/audit/audit-query.service.spec.ts b/src/modules/audit/audit-query.service.spec.ts new file mode 100644 index 0000000..aec83b0 --- /dev/null +++ b/src/modules/audit/audit-query.service.spec.ts @@ -0,0 +1,360 @@ +import { NotFoundException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditLog } from 'src/entities/audit-log.entity'; +import { AuditQueryService } from './audit-query.service'; +import { AuditAction } from './audit-action.enum'; + +describe('AuditQueryService', () => { + let service: AuditQueryService; + let em: { + findAndCount: jest.Mock; + findOneOrFail: jest.Mock; + }; + + const sampleLog = { + id: 'log-1', + action: AuditAction.AUTH_LOGIN_SUCCESS, + actorId: 'user-1', + actorUsername: 'admin', + resourceType: 'User', + resourceId: 'user-1', + metadata: { strategyUsed: 'LocalLoginStrategy' }, + browserName: 'Chrome', + os: 'Linux', + ipAddress: '127.0.0.1', + occurredAt: new Date('2026-03-29T12:00:00.000Z'), + } as AuditLog; + + beforeEach(async () => { + em = { + findAndCount: jest.fn().mockResolvedValue([[sampleLog], 1]), + findOneOrFail: jest.fn().mockResolvedValue(sampleLog), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [AuditQueryService, { provide: EntityManager, useValue: em }], + }).compile(); + + service = module.get(AuditQueryService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('ListAuditLogs', () => { + it('should return paginated results with correct meta', async () => { + const result = await service.ListAuditLogs({ page: 1, limit: 10 }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe('log-1'); + expect(result.data[0].action).toBe(AuditAction.AUTH_LOGIN_SUCCESS); + expect(result.meta).toEqual({ + totalItems: 1, + itemCount: 1, + itemsPerPage: 10, + totalPages: 1, + currentPage: 1, + }); + }); + + it('should pass softDelete: false filter', async () => { + await service.ListAuditLogs({}); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + filters: { softDelete: false }, + }), + ); + }); + + it('should order by occurredAt DESC, id DESC', async () => { + await service.ListAuditLogs({}); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + orderBy: { occurredAt: 'DESC', id: 'DESC' }, + }), + ); + }); + + it('should compute correct offset for pagination', async () => { + await service.ListAuditLogs({ page: 3, limit: 15 }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + limit: 15, + offset: 30, + }), + ); + }); + + it('should apply exact match filter for action', async () => { + await service.ListAuditLogs({ + action: AuditAction.AUTH_LOGIN_SUCCESS, + }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + action: AuditAction.AUTH_LOGIN_SUCCESS, + }), + expect.any(Object), + ); + }); + + it('should apply exact match filter for actorId', async () => { + await service.ListAuditLogs({ actorId: 'user-1' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ actorId: 'user-1' }), + expect.any(Object), + ); + }); + + it('should apply ILIKE partial match for actorUsername', async () => { + await service.ListAuditLogs({ actorUsername: 'john' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + actorUsername: { $ilike: '%john%' }, + }), + expect.any(Object), + ); + }); + + it('should apply exact match filter for resourceType', async () => { + await service.ListAuditLogs({ resourceType: 'User' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ resourceType: 'User' }), + expect.any(Object), + ); + }); + + it('should apply exact match filter for resourceId', async () => { + await service.ListAuditLogs({ resourceId: 'res-1' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ resourceId: 'res-1' }), + expect.any(Object), + ); + }); + + it('should apply date range filter with from only', async () => { + await service.ListAuditLogs({ from: '2026-01-01T00:00:00.000Z' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + occurredAt: { $gte: new Date('2026-01-01T00:00:00.000Z') }, + }), + expect.any(Object), + ); + }); + + it('should apply date range filter with to only', async () => { + await service.ListAuditLogs({ to: '2026-12-31T23:59:59.999Z' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + occurredAt: { $lte: new Date('2026-12-31T23:59:59.999Z') }, + }), + expect.any(Object), + ); + }); + + it('should apply date range filter with both from and to', async () => { + await service.ListAuditLogs({ + from: '2026-01-01T00:00:00.000Z', + to: '2026-12-31T23:59:59.999Z', + }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + occurredAt: { + $gte: new Date('2026-01-01T00:00:00.000Z'), + $lte: new Date('2026-12-31T23:59:59.999Z'), + }, + }), + expect.any(Object), + ); + }); + + it('should apply general text search across multiple fields', async () => { + await service.ListAuditLogs({ search: 'login' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + $or: [ + { actorUsername: { $ilike: '%login%' } }, + { action: { $ilike: '%login%' } }, + { resourceType: { $ilike: '%login%' } }, + ], + }), + expect.any(Object), + ); + }); + + it('should escape LIKE special characters in actorUsername', async () => { + await service.ListAuditLogs({ actorUsername: '100%_done' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + actorUsername: { $ilike: '%100\\%\\_done%' }, + }), + expect.any(Object), + ); + }); + + it('should escape LIKE special characters in search', async () => { + await service.ListAuditLogs({ search: '50%' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + $or: [ + { actorUsername: { $ilike: '%50\\%%' } }, + { action: { $ilike: '%50\\%%' } }, + { resourceType: { $ilike: '%50\\%%' } }, + ], + }), + expect.any(Object), + ); + }); + + it('should return empty data and zero meta when no results', async () => { + em.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.ListAuditLogs({}); + + expect(result).toEqual({ + data: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: 10, + totalPages: 0, + currentPage: 1, + }, + }); + }); + + it('should combine multiple filters', async () => { + await service.ListAuditLogs({ + action: AuditAction.AUTH_LOGIN_SUCCESS, + actorId: 'user-1', + resourceType: 'User', + }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + { + action: AuditAction.AUTH_LOGIN_SUCCESS, + actorId: 'user-1', + resourceType: 'User', + }, + expect.any(Object), + ); + }); + + it('should trim actorUsername before searching', async () => { + await service.ListAuditLogs({ actorUsername: ' john ' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + actorUsername: { $ilike: '%john%' }, + }), + expect.any(Object), + ); + }); + + it('should trim search before searching', async () => { + await service.ListAuditLogs({ search: ' login ' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + $or: [ + { actorUsername: { $ilike: '%login%' } }, + { action: { $ilike: '%login%' } }, + { resourceType: { $ilike: '%login%' } }, + ], + }), + expect.any(Object), + ); + }); + + it('should use default page 1 and limit 10 when not specified', async () => { + await service.ListAuditLogs({}); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + offset: 0, + limit: 10, + }), + ); + }); + }); + + describe('GetAuditLog', () => { + it('should return a mapped audit log detail', async () => { + const result = await service.GetAuditLog('log-1'); + + expect(result.id).toBe('log-1'); + expect(result.action).toBe(AuditAction.AUTH_LOGIN_SUCCESS); + expect(result.actorId).toBe('user-1'); + expect(result.actorUsername).toBe('admin'); + expect(result.metadata).toEqual({ + strategyUsed: 'LocalLoginStrategy', + }); + expect(result.occurredAt).toEqual(new Date('2026-03-29T12:00:00.000Z')); + }); + + it('should pass softDelete: false filter to findOneOrFail', async () => { + await service.GetAuditLog('log-1'); + + expect(em.findOneOrFail).toHaveBeenCalledWith( + AuditLog, + { id: 'log-1' }, + expect.objectContaining({ + filters: { softDelete: false }, + }), + ); + }); + + it('should throw NotFoundException when audit log does not exist', async () => { + em.findOneOrFail.mockImplementation( + ( + _entity: unknown, + _where: unknown, + opts: { failHandler: () => Error }, + ) => { + throw opts.failHandler(); + }, + ); + + await expect(service.GetAuditLog('non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/audit/audit-query.service.ts b/src/modules/audit/audit-query.service.ts new file mode 100644 index 0000000..b6a9049 --- /dev/null +++ b/src/modules/audit/audit-query.service.ts @@ -0,0 +1,104 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { AuditLog } from 'src/entities/audit-log.entity'; +import { ListAuditLogsQueryDto } from './dto/requests/list-audit-logs-query.dto'; +import { AuditLogItemResponseDto } from './dto/responses/audit-log-item.response.dto'; +import { AuditLogListResponseDto } from './dto/responses/audit-log-list.response.dto'; +import { AuditLogDetailResponseDto } from './dto/responses/audit-log-detail.response.dto'; + +@Injectable() +export class AuditQueryService { + constructor(private readonly em: EntityManager) {} + + async ListAuditLogs( + query: ListAuditLogsQueryDto, + ): Promise { + const page = query.page ?? 1; + const limit = query.limit ?? 10; + const offset = (page - 1) * limit; + + const [logs, totalItems] = await this.em.findAndCount( + AuditLog, + this.BuildFilter(query), + { + limit, + offset, + orderBy: { occurredAt: 'DESC', id: 'DESC' }, + filters: { softDelete: false }, + }, + ); + + return { + data: logs.map((log) => AuditLogItemResponseDto.Map(log)), + meta: { + totalItems, + itemCount: logs.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; + } + + async GetAuditLog(id: string): Promise { + const log = await this.em.findOneOrFail( + AuditLog, + { id }, + { + filters: { softDelete: false }, + failHandler: () => new NotFoundException('Audit log not found'), + }, + ); + + return AuditLogDetailResponseDto.Map(log); + } + + private BuildFilter(query: ListAuditLogsQueryDto): FilterQuery { + const filter: FilterQuery = {}; + + if (query.action) { + filter.action = query.action; + } + + if (query.actorId) { + filter.actorId = query.actorId; + } + + if (query.actorUsername) { + filter.actorUsername = { + $ilike: `%${this.EscapeLikePattern(query.actorUsername.trim())}%`, + }; + } + + if (query.resourceType) { + filter.resourceType = query.resourceType; + } + + if (query.resourceId) { + filter.resourceId = query.resourceId; + } + + if (query.from || query.to) { + const occurredAtFilter: Record = {}; + if (query.from) occurredAtFilter.$gte = new Date(query.from); + if (query.to) occurredAtFilter.$lte = new Date(query.to); + filter.occurredAt = occurredAtFilter as never; + } + + if (query.search) { + const search = `%${this.EscapeLikePattern(query.search.trim())}%`; + filter.$or = [ + { actorUsername: { $ilike: search } }, + { action: { $ilike: search } }, + { resourceType: { $ilike: search } }, + ]; + } + + return filter; + } + + private EscapeLikePattern(value: string): string { + return value.replace(/[%_\\]/g, '\\$&'); + } +} diff --git a/src/modules/audit/audit.controller.spec.ts b/src/modules/audit/audit.controller.spec.ts new file mode 100644 index 0000000..f64c9fa --- /dev/null +++ b/src/modules/audit/audit.controller.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from 'src/security/guards/roles.guard'; +import { AuditController } from './audit.controller'; +import { AuditQueryService } from './audit-query.service'; +import { ListAuditLogsQueryDto } from './dto/requests/list-audit-logs-query.dto'; + +describe('AuditController', () => { + let controller: AuditController; + let auditQueryService: { + ListAuditLogs: jest.Mock; + GetAuditLog: jest.Mock; + }; + + beforeEach(async () => { + auditQueryService = { + ListAuditLogs: jest.fn().mockResolvedValue({ + data: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: 10, + totalPages: 0, + currentPage: 1, + }, + }), + GetAuditLog: jest.fn().mockResolvedValue({ + id: 'log-1', + action: 'auth.login.success', + occurredAt: new Date(), + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuditController], + providers: [{ provide: AuditQueryService, useValue: auditQueryService }], + }) + .overrideGuard(AuthGuard('jwt')) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(AuditController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should delegate audit log listing to the query service', async () => { + const query: ListAuditLogsQueryDto = { + action: 'auth.login.success', + page: 2, + limit: 15, + }; + + await controller.ListAuditLogs(query); + + expect(auditQueryService.ListAuditLogs).toHaveBeenCalledWith(query); + }); + + it('should delegate single audit log retrieval to the query service', async () => { + await controller.GetAuditLog('log-1'); + + expect(auditQueryService.GetAuditLog).toHaveBeenCalledWith('log-1'); + }); +}); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..889c7b3 --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -0,0 +1,117 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { UseJwtGuard } from 'src/security/decorators'; +import { UserRole } from 'src/modules/auth/roles.enum'; +import { AuditQueryService } from './audit-query.service'; +import { ListAuditLogsQueryDto } from './dto/requests/list-audit-logs-query.dto'; +import { AuditLogListResponseDto } from './dto/responses/audit-log-list.response.dto'; +import { AuditLogDetailResponseDto } from './dto/responses/audit-log-detail.response.dto'; + +@ApiTags('Audit') +@Controller('audit-logs') +@UseJwtGuard(UserRole.SUPER_ADMIN) +@ApiBearerAuth() +export class AuditController { + constructor(private readonly auditQueryService: AuditQueryService) {} + + @Get() + @ApiOperation({ summary: 'List audit logs with filters and pagination' }) + @ApiQuery({ + name: 'action', + required: false, + type: String, + example: 'auth.login.success', + description: 'Filter by exact audit action code', + }) + @ApiQuery({ + name: 'actorId', + required: false, + type: String, + example: '3f6dd1dd-8f33-4b2e-bb0b-6ac2d8bbf5d7', + description: 'Filter by actor UUID', + }) + @ApiQuery({ + name: 'actorUsername', + required: false, + type: String, + example: 'admin', + description: 'Filter by actor username (partial match)', + }) + @ApiQuery({ + name: 'resourceType', + required: false, + type: String, + example: 'User', + description: 'Filter by resource type', + }) + @ApiQuery({ + name: 'resourceId', + required: false, + type: String, + example: '9ad12fa1-6286-4461-93f8-33b48d2e5725', + description: 'Filter by resource UUID', + }) + @ApiQuery({ + name: 'from', + required: false, + type: String, + example: '2026-01-01T00:00:00.000Z', + description: 'Lower bound (inclusive) on occurredAt (ISO 8601)', + }) + @ApiQuery({ + name: 'to', + required: false, + type: String, + example: '2026-12-31T23:59:59.999Z', + description: 'Upper bound (inclusive) on occurredAt (ISO 8601)', + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + example: 'login', + description: + 'General text search across actorUsername, action, and resourceType', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + example: 1, + description: 'Page number starting at 1', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + example: 10, + description: 'Items per page, max 100', + }) + @ApiResponse({ status: 200, type: AuditLogListResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — superadmin only' }) + async ListAuditLogs( + @Query() query: ListAuditLogsQueryDto, + ): Promise { + return this.auditQueryService.ListAuditLogs(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single audit log entry by ID' }) + @ApiParam({ name: 'id', type: String, description: 'Audit log UUID' }) + @ApiResponse({ status: 200, type: AuditLogDetailResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid UUID format' }) + @ApiResponse({ status: 404, description: 'Audit log not found' }) + async GetAuditLog( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.auditQueryService.GetAuditLog(id); + } +} diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts index f9b51f2..9050ebe 100644 --- a/src/modules/audit/audit.module.ts +++ b/src/modules/audit/audit.module.ts @@ -3,19 +3,28 @@ import { BullModule } from '@nestjs/bullmq'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { QueueName } from 'src/configurations/common/queue-names'; import { AuditLog } from 'src/entities/audit-log.entity'; +import { User } from 'src/entities/user.entity'; import { AppClsModule } from '../common/cls/cls.module'; import { AuditService } from './audit.service'; import { AuditProcessor } from './audit.processor'; +import { AuditQueryService } from './audit-query.service'; import { AuditInterceptor } from './interceptors/audit.interceptor'; +import { AuditController } from './audit.controller'; @Global() @Module({ imports: [ BullModule.registerQueue({ name: QueueName.AUDIT }), - MikroOrmModule.forFeature([AuditLog]), + MikroOrmModule.forFeature([AuditLog, User]), AppClsModule, ], - providers: [AuditService, AuditProcessor, AuditInterceptor], + controllers: [AuditController], + providers: [ + AuditService, + AuditProcessor, + AuditInterceptor, + AuditQueryService, + ], exports: [AuditService, AuditInterceptor], }) export class AuditModule {} diff --git a/src/modules/audit/dto/requests/list-audit-logs-query.dto.ts b/src/modules/audit/dto/requests/list-audit-logs-query.dto.ts new file mode 100644 index 0000000..990a68c --- /dev/null +++ b/src/modules/audit/dto/requests/list-audit-logs-query.dto.ts @@ -0,0 +1,81 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; +import { PaginationQueryDto } from 'src/modules/common/dto/pagination-query.dto'; + +export class ListAuditLogsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Filter by exact audit action code', + example: 'auth.login.success', + }) + @IsString() + @IsOptional() + @MaxLength(100) + action?: string; + + @ApiPropertyOptional({ + description: 'Filter by actor UUID', + example: '3f6dd1dd-8f33-4b2e-bb0b-6ac2d8bbf5d7', + }) + @IsUUID() + @IsOptional() + actorId?: string; + + @ApiPropertyOptional({ + description: 'Filter by actor username (partial match)', + example: 'admin', + }) + @IsString() + @IsOptional() + @MaxLength(100) + actorUsername?: string; + + @ApiPropertyOptional({ + description: 'Filter by resource type', + example: 'User', + }) + @IsString() + @IsOptional() + @MaxLength(100) + resourceType?: string; + + @ApiPropertyOptional({ + description: 'Filter by resource UUID', + example: '9ad12fa1-6286-4461-93f8-33b48d2e5725', + }) + @IsString() + @IsOptional() + @MaxLength(100) + resourceId?: string; + + @ApiPropertyOptional({ + description: 'Lower bound (inclusive) on occurredAt (ISO 8601)', + example: '2026-01-01T00:00:00.000Z', + }) + @IsDateString() + @IsOptional() + from?: string; + + @ApiPropertyOptional({ + description: 'Upper bound (inclusive) on occurredAt (ISO 8601)', + example: '2026-12-31T23:59:59.999Z', + }) + @IsDateString() + @IsOptional() + to?: string; + + @ApiPropertyOptional({ + description: + 'General text search across actorUsername, action, and resourceType', + example: 'login', + }) + @IsString() + @IsOptional() + @MaxLength(200) + search?: string; +} diff --git a/src/modules/audit/dto/responses/audit-log-detail.response.dto.ts b/src/modules/audit/dto/responses/audit-log-detail.response.dto.ts new file mode 100644 index 0000000..ae3f29f --- /dev/null +++ b/src/modules/audit/dto/responses/audit-log-detail.response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AuditLog } from 'src/entities/audit-log.entity'; + +export class AuditLogDetailResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ example: 'auth.login.success' }) + action: string; + + @ApiPropertyOptional() + actorId?: string; + + @ApiPropertyOptional() + actorUsername?: string; + + @ApiPropertyOptional() + resourceType?: string; + + @ApiPropertyOptional() + resourceId?: string; + + @ApiPropertyOptional() + metadata?: Record; + + @ApiPropertyOptional() + browserName?: string; + + @ApiPropertyOptional() + os?: string; + + @ApiPropertyOptional() + ipAddress?: string; + + @ApiProperty() + occurredAt: Date; + + static Map(entity: AuditLog): AuditLogDetailResponseDto { + return { + id: entity.id, + action: entity.action, + actorId: entity.actorId, + actorUsername: entity.actorUsername, + resourceType: entity.resourceType, + resourceId: entity.resourceId, + metadata: entity.metadata, + browserName: entity.browserName, + os: entity.os, + ipAddress: entity.ipAddress, + occurredAt: entity.occurredAt, + }; + } +} diff --git a/src/modules/audit/dto/responses/audit-log-item.response.dto.ts b/src/modules/audit/dto/responses/audit-log-item.response.dto.ts new file mode 100644 index 0000000..14c199e --- /dev/null +++ b/src/modules/audit/dto/responses/audit-log-item.response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AuditLog } from 'src/entities/audit-log.entity'; + +export class AuditLogItemResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ example: 'auth.login.success' }) + action: string; + + @ApiPropertyOptional() + actorId?: string; + + @ApiPropertyOptional() + actorUsername?: string; + + @ApiPropertyOptional() + resourceType?: string; + + @ApiPropertyOptional() + resourceId?: string; + + @ApiPropertyOptional() + metadata?: Record; + + @ApiPropertyOptional() + browserName?: string; + + @ApiPropertyOptional() + os?: string; + + @ApiPropertyOptional() + ipAddress?: string; + + @ApiProperty() + occurredAt: Date; + + static Map(entity: AuditLog): AuditLogItemResponseDto { + return { + id: entity.id, + action: entity.action, + actorId: entity.actorId, + actorUsername: entity.actorUsername, + resourceType: entity.resourceType, + resourceId: entity.resourceId, + metadata: entity.metadata, + browserName: entity.browserName, + os: entity.os, + ipAddress: entity.ipAddress, + occurredAt: entity.occurredAt, + }; + } +} diff --git a/src/modules/audit/dto/responses/audit-log-list.response.dto.ts b/src/modules/audit/dto/responses/audit-log-list.response.dto.ts new file mode 100644 index 0000000..d5e31eb --- /dev/null +++ b/src/modules/audit/dto/responses/audit-log-list.response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationMeta } from 'src/modules/common/dto/pagination.dto'; +import { AuditLogItemResponseDto } from './audit-log-item.response.dto'; + +export class AuditLogListResponseDto { + @ApiProperty({ type: [AuditLogItemResponseDto] }) + data: AuditLogItemResponseDto[]; + + @ApiProperty({ type: PaginationMeta }) + meta: PaginationMeta; +}