From 6c6589953573568d0f21765d4b248b86d12a994b Mon Sep 17 00:00:00 2001 From: Mateus Martinez Rosa Date: Tue, 5 Aug 2025 17:12:48 -0300 Subject: [PATCH 1/4] feat: Implementando enpoint v2 --- .../relatorios/dto/filter-relatorio-v2.dto.ts | 51 +++++ .../reports/relatorios/reports.controller.ts | 21 ++ .../src/reports/relatorios/reports.service.ts | 198 +++++++++++++++++- 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts diff --git a/backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts b/backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts new file mode 100644 index 0000000000..b633f1ed15 --- /dev/null +++ b/backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { FonteRelatorio, RelatorioVisibilidade, TipoRelatorio } from '@prisma/client'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class FilterRelatorioV2Dto { + @IsOptional() + @IsInt() + @Transform(({ value }: any) => (value ? +value : undefined)) + pdm_id?: number; + + @IsOptional() + @ApiProperty({ enum: FonteRelatorio, enumName: 'FonteRelatorio' }) + @IsEnum(FonteRelatorio) + fonte?: FonteRelatorio; + + @IsOptional() + @ApiProperty({ enum: TipoRelatorio, enumName: 'TipoRelatorio' }) + @IsEnum(TipoRelatorio) + tipo?: TipoRelatorio; + + @IsOptional() + @ApiProperty({ enum: RelatorioVisibilidade, enumName: 'RelatorioVisibilidade' }) + @IsEnum(RelatorioVisibilidade) + visibilidade?: RelatorioVisibilidade; + + @IsOptional() + @IsDateString() + criado_em_de?: string; + + @IsOptional() + @IsDateString() + criado_em_ate?: string; + + @IsOptional() + @IsString() + token_paginacao?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Transform((a: TransformFnParams) => (a.value === '' ? 1 : +a.value)) + pagina?: number = 1; + + @IsOptional() + @IsInt() + @Max(500) + @Min(1) + @Transform((a: TransformFnParams) => (a.value === '' ? 25 : +a.value)) + ipp?: number = 25; +} diff --git a/backend/src/reports/relatorios/reports.controller.ts b/backend/src/reports/relatorios/reports.controller.ts index ee14f7aa11..e08754b2de 100644 --- a/backend/src/reports/relatorios/reports.controller.ts +++ b/backend/src/reports/relatorios/reports.controller.ts @@ -12,6 +12,9 @@ import { CreateReportDto } from './dto/create-report.dto'; import { FilterRelatorioDto } from './dto/filter-relatorio.dto'; import { RelatorioDto } from './entities/report.entity'; import { ReportsService } from './reports.service'; +import { PaginatedWithPagesDto } from '../../common/dto/paginated.dto'; +import { ApiPaginatedWithPagesResponse } from '../../auth/decorators/paginated.decorator'; +import { FilterRelatorioV2Dto } from './dto/filter-relatorio-v2.dto'; @ApiTags('Relatórios') @Controller('relatorios') @@ -82,4 +85,22 @@ export class ReportsController { await this.reportsService.syncRelatoriosParametros(); return ''; } + + @ApiBearerAuth('access-token') + @Get('v2') + @Roles([ + 'Reports.executar.CasaCivil', + 'Reports.executar.PDM', + 'Reports.executar.Projetos', + 'Reports.executar.MDO', + 'Reports.executar.PlanoSetorial', + 'Reports.executar.ProgramaDeMetas', + ]) + @ApiPaginatedWithPagesResponse(RelatorioDto) // New decorator for counted pagination + async findAllV2( + @Query() filters: FilterRelatorioV2Dto, // Use the new DTO + @CurrentUser() user: PessoaFromJwt + ): Promise> { // Use the new response type + return await this.reportsService.findAllV2(filters, user); + } } diff --git a/backend/src/reports/relatorios/reports.service.ts b/backend/src/reports/relatorios/reports.service.ts index aba3e47b79..5d247f1c02 100644 --- a/backend/src/reports/relatorios/reports.service.ts +++ b/backend/src/reports/relatorios/reports.service.ts @@ -21,7 +21,7 @@ import { uuidv7 } from 'uuidv7'; import * as XLSX from 'xlsx'; import { PessoaFromJwt } from '../../auth/models/PessoaFromJwt'; import { SYSTEM_TIMEZONE } from '../../common/date2ymd'; -import { PaginatedDto, PAGINATION_TOKEN_TTL } from '../../common/dto/paginated.dto'; +import { PaginatedDto, PaginatedWithPagesDto, PAGINATION_TOKEN_TTL } from '../../common/dto/paginated.dto'; import { RecordWithId } from '../../common/dto/record-with-id.dto'; import { SmaeConfigService } from '../../common/services/smae-config.service'; import { PessoaService } from '../../pessoa/pessoa.service'; @@ -47,6 +47,8 @@ import { FilterRelatorioDto } from './dto/filter-relatorio.dto'; import { RelatorioDto, RelatorioProcessamentoDto } from './entities/report.entity'; import { ReportContext } from './helpers/reports.contexto'; import { BuildParametrosProcessados, ParseBffParamsProcessados } from './helpers/reports.params-processado'; +import { FilterRelatorioV2Dto } from './dto/filter-relatorio-v2.dto'; +import { Object2Hash } from 'src/common/object2hash'; export const GetTempFileName = function (prefix?: string, suffix?: string) { prefix = typeof prefix !== 'undefined' ? prefix : 'tmp.'; @@ -62,6 +64,13 @@ class NextPageTokenJwtBody { ipp: number; } +class ReportsPageTokenJwtBody { + search_hash: string; + ipp: number; + issued_at: number; + total_rows: number; +} + @Injectable() export class ReportsService { private readonly logger = new Logger(ReportsService.name); @@ -827,4 +836,191 @@ export class ReportsService { return 'Desconhecido'; } } + + private _getWhereClauseForFindAll(filters: FilterRelatorioV2Dto, user: PessoaFromJwt): Prisma.RelatorioWhereInput { + const sistema = user.assertOneModuloSistema('buscar', 'Relatórios'); + const criadoEmFilter = + filters.criado_em_de || filters.criado_em_ate + ? { + criado_em: { + gte: filters.criado_em_de ? new Date(filters.criado_em_de) : undefined, + lte: filters.criado_em_ate ? new Date(filters.criado_em_ate) : undefined, + }, + } + : {}; + return { + fonte: filters.fonte, + pdm_id: filters.pdm_id, + tipo: filters.tipo, + visibilidade: filters.visibilidade, + removido_em: null, + sistema: { in: [sistema, 'SMAE'] }, + ...criadoEmFilter, + AND: [ + { + OR: [ + { + visibilidade: 'Privado', + criado_por: user.id, + }, + { + visibilidade: 'Publico', + }, + { + visibilidade: 'Restrito', + OR: [ + // Se não há restrição definida + { + restrito_para: { + equals: Prisma.AnyNull, + }, + }, + // Ou se o usuário atende às restrições de cargo e/ou órgão + { + AND: [ + { + OR: [ + { + restrito_para: { + path: ['$.roles'], + equals: Prisma.AnyNull, + }, + }, + { + restrito_para: { + path: ['$.roles'], + array_contains: user.privilegios as string[], + }, + }, + ], + }, + { + OR: [ + { + restrito_para: { + path: ['$.portfolio_orgao_ids'], + equals: Prisma.AnyNull, + }, + }, + user.orgao_id + ? { + restrito_para: { + path: ['$.portfolio_orgao_ids'], + array_contains: [user.orgao_id], + }, + } + : {}, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + } + + async findAllV2(filters: FilterRelatorioV2Dto, user: PessoaFromJwt): Promise> { + const ipp = filters.ipp ?? 25; + const page = filters.pagina ?? 1; + const skip = (page - 1) * ipp; + let total_registros = 0; + let token_paginacao = filters.token_paginacao; + + const filtersForHash = { ...filters }; + delete filtersForHash.pagina; + delete filtersForHash.token_paginacao; + + if (token_paginacao) { + const decoded = this.decodePageToken(token_paginacao, filtersForHash); + total_registros = decoded.total_rows; + } else { + const where = this._getWhereClauseForFindAll(filters, user); + total_registros = await this.prisma.relatorio.count({ where }); + token_paginacao = this.encodePageToken(filtersForHash, total_registros); + } + + const where = this._getWhereClauseForFindAll(filters, user); + + const rows = await this.prisma.relatorio.findMany({ + where, + select: { + id: true, + criado_em: true, + criador: { select: { nome_exibicao: true } }, + fonte: true, + visibilidade: true, + arquivo_id: true, + parametros: true, + parametros_processados: true, + pdm_id: true, + progresso: true, + err_msg: true, + iniciado_em: true, + processado_em: true, + resumo_saida: true, + }, + orderBy: { criado_em: 'desc' }, + skip, + take: ipp, + }); + + const total_paginas = Math.ceil(total_registros / ipp); + + return { + linhas: rows.map((r) => { + const progresso = r.arquivo_id ? 100 : r.progresso == -1 ? null : r.progresso; + + const eh_publico: boolean = r.visibilidade === RelatorioVisibilidade.Publico ? true : false; + + return { + ...r, + progresso: progresso, + eh_publico: eh_publico, + parametros_processados: ParseBffParamsProcessados(r.parametros_processados?.valueOf(), r.fonte), + criador: { nome_exibicao: r.criador?.nome_exibicao || '(sistema)' }, + arquivo: r.arquivo_id + ? this.uploadService.getDownloadToken(r.arquivo_id, '1d').download_token + : null, + processamento: { + id: 0, + congelado_em: r.iniciado_em, + executado_em: r.processado_em, + err_msg: r.err_msg, + } satisfies RelatorioProcessamentoDto, + resumo_saida: r.resumo_saida?.valueOf() as object[] | null, + } satisfies RelatorioDto; + }), + total_registros: total_registros, + paginas: total_paginas, + pagina_corrente: page, + tem_mais: page < total_paginas, + token_paginacao: token_paginacao, + token_ttl: PAGINATION_TOKEN_TTL, + }; + } + + private decodePageToken(jwt: string, filters: object): ReportsPageTokenJwtBody { + try { + const decoded = this.jwtService.verify(jwt) as ReportsPageTokenJwtBody; + if (decoded.search_hash !== Object2Hash(filters)) { + throw new Error('Filter criteria changed during pagination.'); + } + return decoded; + } catch (error) { + throw new HttpException(`Token de paginação inválido ou expirado. ${error.message}`, 400); + } + } + + private encodePageToken(filters: object, total_rows: number): string { + const body: ReportsPageTokenJwtBody = { + search_hash: Object2Hash(filters), + ipp: (filters as FilterRelatorioV2Dto).ipp ?? 25, + issued_at: Date.now(), + total_rows, + }; + return this.jwtService.sign(body, { expiresIn: PAGINATION_TOKEN_TTL }); + } } From 7c80d23f08879f839cb529bed7c61971234920e8 Mon Sep 17 00:00:00 2001 From: Mateus Martinez Rosa Date: Wed, 6 Aug 2025 11:07:02 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20refatora=20permis=C3=A3o=20de=20visi?= =?UTF-8?q?bilidade=20para=20uma=20fun=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/reports/relatorios/reports.service.ts | 98 ++++++++++--------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/backend/src/reports/relatorios/reports.service.ts b/backend/src/reports/relatorios/reports.service.ts index 5d247f1c02..f969e81113 100644 --- a/backend/src/reports/relatorios/reports.service.ts +++ b/backend/src/reports/relatorios/reports.service.ts @@ -856,62 +856,68 @@ export class ReportsService { removido_em: null, sistema: { in: [sistema, 'SMAE'] }, ...criadoEmFilter, - AND: [ + AND: this._getPermissionClause(user), + }; + } + + private _getPermissionClause(user: PessoaFromJwt): Prisma.RelatorioWhereInput { + return { + OR: [ { + visibilidade: 'Privado', + criado_por: user.id, + }, + { + visibilidade: 'Publico', + }, + { + visibilidade: 'Restrito', OR: [ + // If there's no restriction at all { - visibilidade: 'Privado', - criado_por: user.id, - }, - { - visibilidade: 'Publico', + restrito_para: { + equals: Prisma.AnyNull, + }, }, + // Check for role-based access { - visibilidade: 'Restrito', - OR: [ - // Se não há restrição definida + AND: [ { - restrito_para: { - equals: Prisma.AnyNull, - }, - }, - // Ou se o usuário atende às restrições de cargo e/ou órgão - { - AND: [ + OR: [ + // Either roles doesn't exist in the JSON { - OR: [ - { - restrito_para: { - path: ['$.roles'], - equals: Prisma.AnyNull, - }, - }, - { - restrito_para: { - path: ['$.roles'], - array_contains: user.privilegios as string[], - }, - }, - ], + restrito_para: { + path: ['$.roles'], + equals: Prisma.AnyNull, + }, }, + // Or user has one of the required roles { - OR: [ - { - restrito_para: { - path: ['$.portfolio_orgao_ids'], - equals: Prisma.AnyNull, - }, - }, - user.orgao_id - ? { - restrito_para: { - path: ['$.portfolio_orgao_ids'], - array_contains: [user.orgao_id], - }, - } - : {}, - ], + restrito_para: { + path: ['$.roles'], + array_contains: user.privilegios as string[], + }, + }, + ], + }, + { + OR: [ + // Either portfolio_orgao_ids doesn't exist in the JSON + { + restrito_para: { + path: ['$.portfolio_orgao_ids'], + equals: Prisma.AnyNull, + }, }, + // Or user belongs to one of the required orgs + user.orgao_id + ? { + restrito_para: { + path: ['$.portfolio_orgao_ids'], + array_contains: [user.orgao_id], + }, + } + : {}, ], }, ], From c8dc071819e70343b5575db8d8c848e457c84eab Mon Sep 17 00:00:00 2001 From: Mateus Martinez Rosa Date: Wed, 6 Aug 2025 12:53:10 -0300 Subject: [PATCH 3/4] =?UTF-8?q?refatoracao:=20Centraliza=20a=20logica=20de?= =?UTF-8?q?=20permiss=C3=A3o=20e=20mapeamento=20para=20corrigir=20a=20dupl?= =?UTF-8?q?ica=C3=A7=C3=A3o=20apontada=20pelo=20SonarCloud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/reports/relatorios/reports.service.ts | 143 ++++-------------- 1 file changed, 30 insertions(+), 113 deletions(-) diff --git a/backend/src/reports/relatorios/reports.service.ts b/backend/src/reports/relatorios/reports.service.ts index f969e81113..27c01abe4c 100644 --- a/backend/src/reports/relatorios/reports.service.ts +++ b/backend/src/reports/relatorios/reports.service.ts @@ -402,73 +402,7 @@ export class ReportsService { pdm_id: filters.pdm_id, removido_em: null, sistema: { in: [sistema, 'SMAE'] }, - AND: [ - { - OR: [ - { - visibilidade: 'Privado', - criado_por: user.id, - }, - { - visibilidade: 'Publico', - }, - { - visibilidade: 'Restrito', - OR: [ - // If there's no restriction at all - { - restrito_para: { - equals: Prisma.AnyNull, - }, - }, - // Check for role-based access - { - AND: [ - { - OR: [ - // Either roles doesn't exist in the JSON - { - restrito_para: { - path: ['$.roles'], - equals: Prisma.AnyNull, - }, - }, - // Or user has one of the required roles - { - restrito_para: { - path: ['$.roles'], - array_contains: user.privilegios as string[], - }, - }, - ], - }, - { - OR: [ - // Either portfolio_orgao_ids doesn't exist in the JSON - { - restrito_para: { - path: ['$.portfolio_orgao_ids'], - equals: Prisma.AnyNull, - }, - }, - // Or user belongs to one of the required orgs - user.orgao_id - ? { - restrito_para: { - path: ['$.portfolio_orgao_ids'], - array_contains: [user.orgao_id], - }, - } - : {}, - ], - }, - ], - }, - ], - }, - ], - }, - ], + AND: this._getPermissionClause(user), }, select: { id: true, @@ -500,29 +434,7 @@ export class ReportsService { } return { - linhas: rows.map((r) => { - const progresso = r.arquivo_id ? 100 : r.progresso == -1 ? null : r.progresso; - - const eh_publico: boolean = r.visibilidade === RelatorioVisibilidade.Publico ? true : false; - - return { - ...r, - progresso: progresso, - eh_publico: eh_publico, - parametros_processados: ParseBffParamsProcessados(r.parametros_processados?.valueOf(), r.fonte), - criador: { nome_exibicao: r.criador?.nome_exibicao || '(sistema)' }, - arquivo: r.arquivo_id - ? this.uploadService.getDownloadToken(r.arquivo_id, '1d').download_token - : null, - processamento: { - id: 0, - congelado_em: r.iniciado_em, - executado_em: r.processado_em, - err_msg: r.err_msg, - } satisfies RelatorioProcessamentoDto, - resumo_saida: r.resumo_saida?.valueOf() as object[] | null, - } satisfies RelatorioDto; - }), + linhas: rows.map((r) => this._mapRelatorioToDto(r)), tem_mais: tem_mais, token_ttl: PAGINATION_TOKEN_TTL, token_proxima_pagina: token_proxima_pagina, @@ -976,29 +888,7 @@ export class ReportsService { const total_paginas = Math.ceil(total_registros / ipp); return { - linhas: rows.map((r) => { - const progresso = r.arquivo_id ? 100 : r.progresso == -1 ? null : r.progresso; - - const eh_publico: boolean = r.visibilidade === RelatorioVisibilidade.Publico ? true : false; - - return { - ...r, - progresso: progresso, - eh_publico: eh_publico, - parametros_processados: ParseBffParamsProcessados(r.parametros_processados?.valueOf(), r.fonte), - criador: { nome_exibicao: r.criador?.nome_exibicao || '(sistema)' }, - arquivo: r.arquivo_id - ? this.uploadService.getDownloadToken(r.arquivo_id, '1d').download_token - : null, - processamento: { - id: 0, - congelado_em: r.iniciado_em, - executado_em: r.processado_em, - err_msg: r.err_msg, - } satisfies RelatorioProcessamentoDto, - resumo_saida: r.resumo_saida?.valueOf() as object[] | null, - } satisfies RelatorioDto; - }), + linhas: rows.map((r) => this._mapRelatorioToDto(r)), total_registros: total_registros, paginas: total_paginas, pagina_corrente: page, @@ -1008,6 +898,33 @@ export class ReportsService { }; } + private _mapRelatorioToDto(relatorioFromDb: any): RelatorioDto { + const progresso = relatorioFromDb.arquivo_id ? 100 : relatorioFromDb.progresso == -1 ? null : relatorioFromDb.progresso; + + const eh_publico: boolean = relatorioFromDb.visibilidade === RelatorioVisibilidade.Publico ? true : false; + + return { + ...relatorioFromDb, + progresso: progresso, + eh_publico: eh_publico, + parametros_processados: ParseBffParamsProcessados( + relatorioFromDb.parametros_processados?.valueOf(), + relatorioFromDb.fonte + ), + criador: { nome_exibicao: relatorioFromDb.criador?.nome_exibicao || '(sistema)' }, + arquivo: relatorioFromDb.arquivo_id + ? this.uploadService.getDownloadToken(relatorioFromDb.arquivo_id, '1d').download_token + : null, + processamento: { + id: 0, + congelado_em: relatorioFromDb.iniciado_em, + executado_em: relatorioFromDb.processado_em, + err_msg: relatorioFromDb.err_msg, + } satisfies RelatorioProcessamentoDto, + resumo_saida: relatorioFromDb.resumo_saida?.valueOf() as object[] | null, + } satisfies RelatorioDto; + } + private decodePageToken(jwt: string, filters: object): ReportsPageTokenJwtBody { try { const decoded = this.jwtService.verify(jwt) as ReportsPageTokenJwtBody; From 57906f750313eedef0298a3567d97c057f04274d Mon Sep 17 00:00:00 2001 From: Renato Santos Date: Fri, 15 Aug 2025 10:37:02 -0300 Subject: [PATCH 4/4] Update backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts b/backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts index b633f1ed15..b9c967cd96 100644 --- a/backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts +++ b/backend/src/reports/relatorios/dto/filter-relatorio-v2.dto.ts @@ -5,8 +5,9 @@ import { IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'cla export class FilterRelatorioV2Dto { @IsOptional() + @ApiProperty({ description: 'ID do PDM para filtrar relatórios', required: false }) @IsInt() - @Transform(({ value }: any) => (value ? +value : undefined)) + @Transform(({ value }: TransformFnParams) => (value === '' || value == null ? undefined : +value)) pdm_id?: number; @IsOptional()