Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/*
Expand Down Expand Up @@ -126,6 +127,7 @@ import { WikiLinkModule } from './wiki-link/wiki-link.module';
BuscaGlobalModule,
AtualizacaoEmLoteModule,
WikiLinkModule,
AuditLogModule
],
controllers: [AppController],
providers: [
Expand Down
31 changes: 31 additions & 0 deletions backend/src/audit-log/audit-log.controller.ts
Original file line number Diff line number Diff line change
@@ -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<AuditLogSummaryDto> {
return {
linhas: await this.auditLogService.getSummary(filters, groupBy),
};
}
}
18 changes: 18 additions & 0 deletions backend/src/audit-log/audit-log.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
148 changes: 148 additions & 0 deletions backend/src/audit-log/audit-log.service.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedDto<AuditLogDto>> {
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<AuditLogSummaryRow[]> {
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<string, AuditLogSummaryRow>();
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);
}
}
61 changes: 61 additions & 0 deletions backend/src/audit-log/dto/audit-log.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions backend/src/audit-log/entities/audit-log.entity.ts
Original file line number Diff line number Diff line change
@@ -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[];
}