diff --git a/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery.ts b/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery.ts new file mode 100644 index 0000000000..f22904ae54 --- /dev/null +++ b/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated'; +import { apiClient } from '@/common/api-client/api-client'; +import { Method } from '@/common/enums'; +import { handleZodError } from '@/common/utils/handle-zod-error/handle-zod-error'; +import { z } from 'zod'; + +export const MetricsResponseSchema = z.object({ + riskLevelCounts: z.object({ + low: z.number(), + medium: z.number(), + high: z.number(), + critical: z.number(), + }), + violationCounts: z.record(z.string(), z.number()), +}); + +export const fetchBusinessReportMetrics = async () => { + const [businessReportMetrics, error] = await apiClient({ + endpoint: `../external/business-reports/metrics`, + method: Method.GET, + schema: MetricsResponseSchema, + }); + + return handleZodError(error, businessReportMetrics); +}; + +export const useBusinessReportMetricsQuery = () => { + const isAuthenticated = useIsAuthenticated(); + + return useQuery({ + queryKey: ['business-report-metrics'], + queryFn: () => fetchBusinessReportMetrics(), + enabled: isAuthenticated, + keepPreviousData: true, + }); +}; diff --git a/apps/backoffice-v2/src/pages/Statistics/Statistics.page.tsx b/apps/backoffice-v2/src/pages/Statistics/Statistics.page.tsx index 268b353e51..a508b636ba 100644 --- a/apps/backoffice-v2/src/pages/Statistics/Statistics.page.tsx +++ b/apps/backoffice-v2/src/pages/Statistics/Statistics.page.tsx @@ -3,10 +3,10 @@ import { UserStatistics } from '@/pages/Statistics/components/UserStatistics/Use import { PortfolioRiskStatistics } from '@/pages/Statistics/components/PortfolioRiskStatistics/PortfolioRiskStatistics'; import { WorkflowStatistics } from '@/pages/Statistics/components/WorkflowStatistics/WorkflowStatistics'; import { Loader2 } from 'lucide-react'; -import { useHomeMetricsQuery } from '@/domains/metrics/hooks/queries/useHomeMetricsQuery/useHomeMetricsQuery'; +import { useBusinessReportMetricsQuery } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery'; export const Statistics: FunctionComponent = () => { - const { data, isLoading, error } = useHomeMetricsQuery(); + const { data, isLoading, error } = useBusinessReportMetricsQuery(); if (error) { throw error; @@ -20,13 +20,12 @@ export const Statistics: FunctionComponent = () => {

Statistics

- + {/* */} - + {/* */}
); diff --git a/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/PortfolioRiskStatistics.tsx b/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/PortfolioRiskStatistics.tsx index 55817ca46d..10e4e73acb 100644 --- a/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/PortfolioRiskStatistics.tsx +++ b/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/PortfolioRiskStatistics.tsx @@ -17,25 +17,24 @@ import { import { titleCase } from 'string-ts'; import { usePortfolioRiskStatisticsLogic } from '@/pages/Statistics/components/PortfolioRiskStatistics/hooks/usePortfolioRiskStatisticsLogic/usePortfolioRiskStatisticsLogic'; import { z } from 'zod'; -import { HomeMetricsOutputSchema } from '@/domains/metrics/hooks/queries/useHomeMetricsQuery/useHomeMetricsQuery'; +import { MetricsResponseSchema } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery'; -export const PortfolioRiskStatistics: FunctionComponent< - z.infer -> = ({ riskIndicators, reports, cases }) => { +export const PortfolioRiskStatistics: FunctionComponent> = ({ + riskLevelCounts, + violationCounts, +}) => { const { riskLevelToFillColor, parent, widths, riskLevelToBackgroundColor, - filters, totalRiskIndicators, riskIndicatorsSorting, onSortRiskIndicators, filteredRiskIndicators, } = usePortfolioRiskStatisticsLogic({ - riskIndicators, - reports, - cases, + riskLevelCounts, + violationCounts, }); return ( @@ -44,10 +43,10 @@ export const PortfolioRiskStatistics: FunctionComponent<
- Portfolio Risk + Merchant Monitoring Risk

- Risk levels of approved merchants from completed onboarding flows. + Risk levels of all merchant monitoring reports.

@@ -58,13 +57,13 @@ export const PortfolioRiskStatistics: FunctionComponent< dominantBaseline="middle" className={'text-lg font-bold'} > - {Object.values(cases.approved).reduce((acc, curr) => acc + curr, 0)} + {Object.values(riskLevelCounts).reduce((acc, curr) => acc + curr, 0)} - Merchants + Reports ({ + data={Object.entries(riskLevelCounts).map(([riskLevel, value]) => ({ name: `${titleCase(riskLevel)} Risk`, value, }))} @@ -89,123 +88,32 @@ export const PortfolioRiskStatistics: FunctionComponent<
    - {Object.entries(cases.approved).map(([riskLevel, value]) => ( -
  • - -
    - {titleCase(riskLevel)} Risk - {value} -
    -
  • - ))} + {Object.entries(riskLevelCounts) + .reverse() + .map(([riskLevel, value]) => ( +
  • + +
    + {titleCase(riskLevel)} Risk + {value} +
    +
  • + ))}
-
- {filters?.map(filter => { - const totalRisk = Object.values(filter.riskLevels).reduce((acc, curr) => acc + curr, 0); - - return ( -
- - {filter.name} Risk - -

{filter.description}

-
- - = 5, - })} - > - {totalRisk} - - - {filter.entityPlural} - - ({ - name: `${titleCase(riskLevel)} Risk`, - value, - }), - )} - cx={47} - cy={47} - innerRadius={43} - outerRadius={52} - fill="#8884d8" - paddingAngle={5} - dataKey="value" - cornerRadius={9999} - > - {Object.keys(riskLevelToFillColor).map(riskLevel => ( - - ))} - - -
    - {Object.entries(filter?.riskLevels ?? {}).map(([riskLevel, value]) => { - return ( -
  • - -
    - - {titleCase(riskLevel)} Risk - - {value} -
    -
  • - ); - })} -
-
-
-
-
- ); - })} -
Risk Indicators diff --git a/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/hooks/usePortfolioRiskStatisticsLogic/usePortfolioRiskStatisticsLogic.tsx b/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/hooks/usePortfolioRiskStatisticsLogic/usePortfolioRiskStatisticsLogic.tsx index 5b67de93d0..596541cb86 100644 --- a/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/hooks/usePortfolioRiskStatisticsLogic/usePortfolioRiskStatisticsLogic.tsx +++ b/apps/backoffice-v2/src/pages/Statistics/components/PortfolioRiskStatistics/hooks/usePortfolioRiskStatisticsLogic/usePortfolioRiskStatisticsLogic.tsx @@ -6,13 +6,12 @@ import { riskLevelToFillColor, } from '@/pages/Statistics/components/PortfolioRiskStatistics/constants'; import { z } from 'zod'; -import { HomeMetricsOutputSchema } from '@/domains/metrics/hooks/queries/useHomeMetricsQuery/useHomeMetricsQuery'; +import { MetricsResponseSchema } from '@/domains/business-reports/hooks/queries/useBusinessReportMetricsQuery/useBusinessReportMetricsQuery'; export const usePortfolioRiskStatisticsLogic = ({ - riskIndicators, - reports, - cases, -}: z.infer) => { + riskLevelCounts, + violationCounts, +}: z.infer) => { const [parent] = useAutoAnimate(); const [riskIndicatorsSorting, setRiskIndicatorsSorting] = useState('desc'); const onSortRiskIndicators = useCallback( @@ -21,9 +20,13 @@ export const usePortfolioRiskStatisticsLogic = ({ }, [], ); - const totalRiskIndicators = riskIndicators.reduce((acc, curr) => acc + curr.count, 0); + const totalRiskIndicators = Object.values(violationCounts).reduce((acc, curr) => acc + curr, 0); const filteredRiskIndicators = useMemo(() => { - return structuredClone(riskIndicators) + return Object.entries(violationCounts) + .map(([name, count]) => ({ + name, + count, + })) .sort((a, b) => { if (riskIndicatorsSorting === 'asc') { return a.count - b.count; @@ -32,7 +35,7 @@ export const usePortfolioRiskStatisticsLogic = ({ return b.count - a.count; }) .slice(0, 5); - }, [riskIndicators, riskIndicatorsSorting]); + }, [violationCounts, riskIndicatorsSorting]); const widths = useMemo(() => { const maxValue = Math.max(...filteredRiskIndicators.map(item => item.count), 0); @@ -40,37 +43,12 @@ export const usePortfolioRiskStatisticsLogic = ({ item.count === 0 ? 0 : Math.max((item.count / maxValue) * 100, 2), ); }, [filteredRiskIndicators]); - const filters = [ - { - name: 'Merchant Monitoring', - description: 'Risk Risk levels of all merchant monitoring reports.', - entityPlural: 'Reports', - riskLevels: { - low: reports.all.low, - medium: reports.all.medium, - high: reports.all.high, - critical: reports.all.critical, - }, - }, - { - name: 'Merchant Onboarding', - description: 'Risk levels of all active onboarding cases.', - entityPlural: 'Cases', - riskLevels: { - low: cases.inProgress.low, - medium: cases.inProgress.medium, - high: cases.inProgress.high, - critical: cases.inProgress.critical, - }, - }, - ]; return { riskLevelToFillColor, parent, widths, riskLevelToBackgroundColor, - filters, riskIndicatorsSorting, onSortRiskIndicators, filteredRiskIndicators, diff --git a/services/workflows-service/prisma/data-migrations b/services/workflows-service/prisma/data-migrations index 186cc70140..bfc772b0ad 160000 --- a/services/workflows-service/prisma/data-migrations +++ b/services/workflows-service/prisma/data-migrations @@ -1 +1 @@ -Subproject commit 186cc70140c064c184f116ba388e31464670b0a8 +Subproject commit bfc772b0ade3ae49465629d6c85ac26aac3796ab diff --git a/services/workflows-service/src/business-report/business-report.controller.external.ts b/services/workflows-service/src/business-report/business-report.controller.external.ts index 74ae494278..90f47afc20 100644 --- a/services/workflows-service/src/business-report/business-report.controller.external.ts +++ b/services/workflows-service/src/business-report/business-report.controller.external.ts @@ -38,6 +38,7 @@ import { PrismaService } from '@/prisma/prisma.service'; import { AdminAuthGuard } from '@/common/guards/admin-auth.guard'; import { BusinessReportFindingsListResponseDto } from '@/business-report/dtos/business-report-findings.dto'; import { MerchantMonitoringClient } from '@/business-report/merchant-monitoring-client'; +import { BusinessReportMetricsDto } from './dtos/business-report-metrics-dto'; @ApiBearerAuth() @swagger.ApiTags('Business Reports') @@ -115,6 +116,15 @@ export class BusinessReportControllerExternal { return await this.merchantMonitoringClient.listFindings(); } + @common.Get('/metrics') + @swagger.ApiOkResponse({ type: BusinessReportMetricsDto }) + @swagger.ApiForbiddenResponse({ type: errors.ForbiddenException }) + async getMetrics(@CurrentProject() currentProjectId: TProjectId) { + const { id: customerId } = await this.customerService.getByProjectId(currentProjectId); + + return await this.merchantMonitoringClient.getMetrics({ customerId }); + } + @common.Post() @swagger.ApiOkResponse({}) @swagger.ApiForbiddenResponse({ type: errors.ForbiddenException }) diff --git a/services/workflows-service/src/business-report/dtos/business-report-metrics-dto.ts b/services/workflows-service/src/business-report/dtos/business-report-metrics-dto.ts new file mode 100644 index 0000000000..ab75feabd6 --- /dev/null +++ b/services/workflows-service/src/business-report/dtos/business-report-metrics-dto.ts @@ -0,0 +1,58 @@ +import { IsNumber, IsObject, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +import { ApiProperty } from '@nestjs/swagger'; + +export class RiskLevelCountsDto { + @ApiProperty({ + description: 'Number of low risk reports', + example: 5, + type: Number, + }) + @IsNumber() + low!: number; + + @ApiProperty({ + description: 'Number of medium risk reports', + example: 3, + type: Number, + }) + @IsNumber() + medium!: number; + + @ApiProperty({ + description: 'Number of high risk reports', + example: 2, + type: Number, + }) + @IsNumber() + high!: number; + + @ApiProperty({ + description: 'Number of critical risk reports', + example: 1, + type: Number, + }) + @IsNumber() + critical!: number; +} + +export class BusinessReportMetricsDto { + @ApiProperty({ + description: 'Counts of reports by risk level', + type: RiskLevelCountsDto, + }) + @ValidateNested() + @Type(() => RiskLevelCountsDto) + riskLevelCounts!: RiskLevelCountsDto; + + @ApiProperty({ + description: 'Counts of violations by type', + example: { PROHIBITED_CONTENT: 2, MISSING_INFORMATION: 1 }, + type: 'object', + additionalProperties: { type: 'number' }, + }) + @IsObject() + @Type(() => Object) + violationCounts!: Record; +} diff --git a/services/workflows-service/src/business-report/merchant-monitoring-client.ts b/services/workflows-service/src/business-report/merchant-monitoring-client.ts index c14e611322..9d9ae81e0a 100644 --- a/services/workflows-service/src/business-report/merchant-monitoring-client.ts +++ b/services/workflows-service/src/business-report/merchant-monitoring-client.ts @@ -64,6 +64,16 @@ const FindManyReportsResponseSchema = z.object({ data: z.array(ReportSchema), }); +const MetricsResponseSchema = z.object({ + riskLevelCounts: z.object({ + low: z.number(), + medium: z.number(), + high: z.number(), + critical: z.number(), + }), + violationCounts: z.record(z.string(), z.number()), +}); + @Injectable() export class MerchantMonitoringClient { private axios: AxiosInstance; @@ -255,4 +265,17 @@ export class MerchantMonitoringClient { return response.data ?? []; } + + public async getMetrics({ customerId }: { customerId: string }) { + const response = await this.axios.get('merchants/analysis/metrics', { + params: { + customerId, + }, + headers: { + Authorization: `Bearer ${env.UNIFIED_API_TOKEN}`, + }, + }); + + return MetricsResponseSchema.parse(response.data); + } }