diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6292ecd560..211afc8087 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -62,6 +62,7 @@ import { TaskModule } from './task/task.module'; import { TransfereGovApiModule } from './transfere-gov-api/transfere-gov-api.module'; import { ClassificacaoModule } from './transferencias-voluntarias/classificacao/classificacao.module'; import { WikiLinkModule } from './wiki-link/wiki-link.module'; +import { AuditLogModule } from './audit-log/audit-log.module'; // Hacks pro JS /* @@ -126,6 +127,7 @@ import { WikiLinkModule } from './wiki-link/wiki-link.module'; BuscaGlobalModule, AtualizacaoEmLoteModule, WikiLinkModule, + AuditLogModule ], controllers: [AppController], providers: [ diff --git a/backend/src/audit-log/audit-log.controller.ts b/backend/src/audit-log/audit-log.controller.ts new file mode 100644 index 0000000000..cc6a9411c1 --- /dev/null +++ b/backend/src/audit-log/audit-log.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { AuditLogService } from "./audit-log.service"; +import { Roles } from "src/auth/decorators/roles.decorator"; +import { FilterAuditLogDto, GroupByFieldsDto, GroupByFilterDto } from "./dto/audit-log.dto"; +import { AuditLogSummaryDto } from "./entities/audit-log.entity"; + +@Controller('audit-log') +@ApiTags('Audit Log') +export class AuditLogController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Get() + @ApiBearerAuth('access-token') + @Roles(['SMAE.superadmin']) + async findAll(@Query() filters: FilterAuditLogDto) { + return await this.auditLogService.findAll(filters); + } + + @Get('/summary') + @ApiBearerAuth('access-token') + @Roles(['SMAE.superadmin']) + async getSummary( + @Query() filters: GroupByFilterDto, + @Query() groupBy: GroupByFieldsDto + ): Promise { + return { + linhas: await this.auditLogService.getSummary(filters, groupBy), + }; + } +} diff --git a/backend/src/audit-log/audit-log.module.ts b/backend/src/audit-log/audit-log.module.ts new file mode 100644 index 0000000000..de755ea975 --- /dev/null +++ b/backend/src/audit-log/audit-log.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { AuditLogController } from './audit-log.controller'; +import { AuditLogService } from './audit-log.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { JwtModule } from '@nestjs/jwt'; + +@Module({ + imports: [ + PrismaModule, + JwtModule.register({ + secret: process.env.SESSION_JWT_SECRET + ':pagination', + signOptions: { expiresIn: '30d' }, + }), + ], + controllers: [AuditLogController], + providers: [AuditLogService], +}) +export class AuditLogModule {} diff --git a/backend/src/audit-log/audit-log.service.ts b/backend/src/audit-log/audit-log.service.ts new file mode 100644 index 0000000000..3bb9548b60 --- /dev/null +++ b/backend/src/audit-log/audit-log.service.ts @@ -0,0 +1,148 @@ +import { BadRequestException, HttpException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { JwtService } from '@nestjs/jwt'; +import { FilterAuditLogDto, GroupByFieldsDto, GroupByFilterDto } from './dto/audit-log.dto'; +import { AuditLogDto, AuditLogSummaryRow } from './entities/audit-log.entity'; +import { PaginatedDto, PAGINATION_TOKEN_TTL } from '../common/dto/paginated.dto'; + +class NextPageTokenJwtBody { + offset!: number; + ipp!: number; +} + +@Injectable() +export class AuditLogService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService + ) {} + + async findAll(filters: FilterAuditLogDto): Promise> { + let tem_mais = false; + let token_proxima_pagina: string | null = null; + + let ipp = filters.ipp ?? 25; + let offset = 0; + + const decodedPageToken = this.decodeNextPageToken(filters.token_proxima_pagina); + if (decodedPageToken) { + offset = decodedPageToken.offset; + ipp = decodedPageToken.ipp; + } + + const where: any = {}; + if (filters.pessoa_id) where.pessoa_id = filters.pessoa_id; + if (filters.contexto) where.contexto = { contains: filters.contexto, mode: 'insensitive' }; + if (filters.log_contem) where.log = { contains: filters.log_contem, mode: 'insensitive' }; + if (filters.ip) where.ip = filters.ip; + if (filters.criado_em_inicio || filters.criado_em_fim) { + where.criado_em = { + gte: filters.criado_em_inicio ? new Date(filters.criado_em_inicio) : undefined, + lte: filters.criado_em_fim ? new Date(filters.criado_em_fim) : undefined, + }; + } + + const rows = await this.prisma.logGenerico.findMany({ + where, + orderBy: [{ criado_em: 'desc' }, { id: 'desc' }], + skip: offset, + take: ipp + 1, + include: { + pessoa: { + select: { nome_exibicao: true }, + }, + }, + }); + + const linhas: AuditLogDto[] = rows.map((r) => ({ + id: r.id, + contexto: r.contexto, + ip: r.ip, + log: r.log, + pessoa_id: r.pessoa_id ?? null, + pessoa_nome: r.pessoa?.nome_exibicao ?? undefined, + pessoa_sessao_id: r.pessoa_sessao_id ?? null, + criado_em: r.criado_em, + })); + + if (linhas.length > ipp) { + tem_mais = true; + linhas.pop(); + token_proxima_pagina = this.encodeNextPageToken({ ipp, offset: offset + ipp }); + } + + return { + tem_mais, + token_ttl: PAGINATION_TOKEN_TTL, + token_proxima_pagina, + linhas, + }; + } + + async getSummary(filters: GroupByFilterDto, groupBy: GroupByFieldsDto): Promise { + const groupByFields: Array<'criado_em' | 'contexto' | 'pessoa_id'> = []; + + if (groupBy.group_by_date) groupByFields.push('criado_em'); + if (groupBy.group_by_contexto) groupByFields.push('contexto'); + if (groupBy.group_by_pessoa_id) groupByFields.push('pessoa_id'); + + if (groupByFields.length === 0) { + throw new BadRequestException('Pelo menos um campo é obrigatório para agrupamento.'); + } + + const summary = await this.prisma.logGenerico.groupBy({ + by: groupByFields, + _count: { + _all: true, + }, + where: { + pessoa_id: filters.pessoa_id, + contexto: filters.contexto, + log: filters.log_contem ? { contains: filters.log_contem, mode: 'insensitive' } : undefined, + ip: filters.ip, + criado_em: { + gte: filters.criado_em_inicio ? new Date(filters.criado_em_inicio) : undefined, + lte: filters.criado_em_fim ? new Date(filters.criado_em_fim) : undefined, + }, + }, + orderBy: [], + }); + + if (groupBy.group_by_date) { + const agg = new Map(); + for (const r of summary) { + const day = r.criado_em ? r.criado_em.toISOString().slice(0, 10) : undefined; // UTC day + const key = JSON.stringify([day, r.contexto, r.pessoa_id]); + const curr = agg.get(key) ?? { + count: 0, + date: day ? new Date(`${day}T00:00:00.000Z`) : undefined, + contexto: r.contexto, + pessoa_id: r.pessoa_id ?? undefined, + }; + curr.count += r._count._all; + agg.set(key, curr); + } + return [...agg.values()].sort((a, b) => b.count - a.count); + } + return summary.map((r) => ({ + count: r._count._all, + date: undefined, + contexto: r.contexto, + pessoa_id: r.pessoa_id ?? undefined, + })); + } + + private decodeNextPageToken(jwt: string | undefined): NextPageTokenJwtBody | null { + let tmp: NextPageTokenJwtBody | null = null; + try { + if (jwt) tmp = this.jwtService.verify(jwt) as NextPageTokenJwtBody; + } catch { + throw new HttpException('Param next_page_token is invalid', 400); + } + return tmp; + } + + private encodeNextPageToken(opt: NextPageTokenJwtBody): string { + return this.jwtService.sign(opt); + } +} diff --git a/backend/src/audit-log/dto/audit-log.dto.ts b/backend/src/audit-log/dto/audit-log.dto.ts new file mode 100644 index 0000000000..b387643807 --- /dev/null +++ b/backend/src/audit-log/dto/audit-log.dto.ts @@ -0,0 +1,61 @@ +import { OmitType } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsDateString, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +export class FilterAuditLogDto { + @IsOptional() + @IsInt() + @Transform((a: TransformFnParams) => (a.value === '' ? undefined : +a.value)) + pessoa_id?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + contexto?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + log_contem?: string; // busca no campo 'log' + + @IsOptional() + @IsString() + @MaxLength(45) + ip?: string; + + @IsOptional() + @IsDateString({ strictSeparator: true, strict: true }) + criado_em_inicio?: string; + + @IsOptional() + @IsDateString({ strictSeparator: true, strict: true }) + criado_em_fim?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + token_proxima_pagina?: string; + + @IsOptional() + @IsInt() + @Max(500) + @Min(1) + @Transform((a: TransformFnParams) => (a.value === '' ? undefined : +a.value)) + ipp?: number; +} + +export class GroupByFilterDto extends OmitType(FilterAuditLogDto, ['token_proxima_pagina', 'ipp']) {} + +export class GroupByFieldsDto { + @IsOptional() + @Transform(({ value }: any) => value === 'true') + group_by_date?: boolean; + + @IsOptional() + @Transform(({ value }: any) => value === 'true') + group_by_contexto?: boolean; + + @IsOptional() + @Transform(({ value }: any) => value === 'true') + group_by_pessoa_id?: boolean; +} diff --git a/backend/src/audit-log/entities/audit-log.entity.ts b/backend/src/audit-log/entities/audit-log.entity.ts new file mode 100644 index 0000000000..969681d43b --- /dev/null +++ b/backend/src/audit-log/entities/audit-log.entity.ts @@ -0,0 +1,21 @@ +export class AuditLogDto { + id: number; + contexto: string; + ip: string; + log: string; + pessoa_id: number | null; + pessoa_nome?: string; // nome_exibicao da pessoa + pessoa_sessao_id: number | null; + criado_em: Date; +} + +export class AuditLogSummaryRow { + count: number; + date?: Date; + contexto?: string; + pessoa_id?: number; +} + +export class AuditLogSummaryDto { + linhas: AuditLogSummaryRow[]; +}