diff --git a/apps/backoffice-v2/src/Router/Router.tsx b/apps/backoffice-v2/src/Router/Router.tsx index be3bea7696..de8ada7675 100644 --- a/apps/backoffice-v2/src/Router/Router.tsx +++ b/apps/backoffice-v2/src/Router/Router.tsx @@ -1,28 +1,31 @@ -import React, { FunctionComponent } from 'react'; -import { env } from '@/common/env/env'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { RootError } from '@/pages/Root/Root.error'; -import { Root } from '@/pages/Root/Root.page'; -import { SignIn } from '@/pages/SignIn/SignIn.page'; -import { Entity } from '@/pages/Entity/Entity.page'; -import { Entities } from '@/pages/Entities/Entities.page'; import { RouteError } from '@/common/components/atoms/RouteError/RouteError'; -import { CaseManagement } from '@/pages/CaseManagement/CaseManagement.page'; -import { rootLoader } from '@/pages/Root/Root.loader'; -import { entitiesLoader } from '@/pages/Entities/Entities.loader'; -import { authenticatedLayoutLoader } from '@/domains/auth/components/AuthenticatedLayout/AuthenticatedLayout.loader'; -import { entityLoader } from '@/pages/Entity/Entity.loader'; +import { env } from '@/common/env/env'; import { AuthenticatedLayout } from '@/domains/auth/components/AuthenticatedLayout'; +import { authenticatedLayoutLoader } from '@/domains/auth/components/AuthenticatedLayout/AuthenticatedLayout.loader'; import { UnauthenticatedLayout } from '@/domains/auth/components/UnauthenticatedLayout'; -import { Locale } from '@/pages/Locale/Locale.page'; import { unauthenticatedLayoutLoader } from '@/domains/auth/components/UnauthenticatedLayout/UnauthenticatedLayout.loader'; +import { Businesses } from '@/pages/Businesses/Businesses'; +import { BusinessesAlerts } from '@/pages/BusinessesAlerts/BusinessesAlerts.page'; +import { BusinessesAlertsAnalysisPage } from '@/pages/BusinessesAlertsAnalysis/BusinessesAlertsAnalysis.page'; +import { CaseManagement } from '@/pages/CaseManagement/CaseManagement.page'; import { Document } from '@/pages/Document/Document.page'; +import { entitiesLoader } from '@/pages/Entities/Entities.loader'; +import { Entities } from '@/pages/Entities/Entities.page'; +import { entityLoader } from '@/pages/Entity/Entity.loader'; +import { Entity } from '@/pages/Entity/Entity.page'; +import { Locale } from '@/pages/Locale/Locale.page'; import { NotFoundRedirect } from '@/pages/NotFound/NotFound'; -import { TransactionMonitoringAlerts } from '@/pages/TransactionMonitoringAlerts/TransactionMonitoringAlerts.page'; +import { Individuals } from '@/pages/Profiles/Individuals/Individuals.page'; +import { Profiles } from '@/pages/Profiles/Profiles.page'; +import { RootError } from '@/pages/Root/Root.error'; +import { rootLoader } from '@/pages/Root/Root.loader'; +import { Root } from '@/pages/Root/Root.page'; +import { SignIn } from '@/pages/SignIn/SignIn.page'; import { TransactionMonitoring } from '@/pages/TransactionMonitoring/TransactionMonitoring'; +import { TransactionMonitoringAlerts } from '@/pages/TransactionMonitoringAlerts/TransactionMonitoringAlerts.page'; import { TransactionMonitoringAlertsAnalysisPage } from '@/pages/TransactionMonitoringAlertsAnalysis/TransactionMonitoringAlertsAnalysis.page'; -import { Profiles } from '@/pages/Profiles/Profiles.page'; -import { Individuals } from '@/pages/Profiles/Individuals/Individuals.page'; +import { FunctionComponent } from 'react'; +import { RouterProvider, createBrowserRouter } from 'react-router-dom'; const router = createBrowserRouter([ { @@ -121,6 +124,25 @@ const router = createBrowserRouter([ }, ], }, + { + path: '/:locale/businesses', + element: , + errorElement: , + children: [ + { + path: '/:locale/businesses/alerts', + element: , + errorElement: , + children: [ + { + path: '/:locale/businesses/alerts/:alertId', + element: , + errorElement: , + }, + ], + }, + ], + }, ], }, ], diff --git a/apps/backoffice-v2/src/common/components/atoms/MultiSelect/MultiSelect.tsx b/apps/backoffice-v2/src/common/components/atoms/MultiSelect/MultiSelect.tsx index 02c64a0242..b9485e6289 100644 --- a/apps/backoffice-v2/src/common/components/atoms/MultiSelect/MultiSelect.tsx +++ b/apps/backoffice-v2/src/common/components/atoms/MultiSelect/MultiSelect.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useState } from 'react'; +import { Separator } from '@/common/components/atoms/Separator/Separator'; import { Badge, Button, @@ -15,7 +15,7 @@ import { PopoverTrigger, } from '@ballerine/ui'; import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'; -import { Separator } from '@/common/components/atoms/Separator/Separator'; +import { ReactNode, useCallback, useState } from 'react'; interface IMultiSelectProps< TOption extends { @@ -63,7 +63,7 @@ export const MultiSelect = < - + {title} {selected?.length > 0 && ( <> @@ -107,13 +107,13 @@ export const MultiSelect = < onSelectChange(option.value)}> - + {option.icon} {option.label} diff --git a/apps/backoffice-v2/src/common/components/atoms/OpenUrlInNewTabButton/OpenUrlInNewTabButton.tsx b/apps/backoffice-v2/src/common/components/atoms/OpenUrlInNewTabButton/OpenUrlInNewTabButton.tsx new file mode 100644 index 0000000000..6f1105f2f8 --- /dev/null +++ b/apps/backoffice-v2/src/common/components/atoms/OpenUrlInNewTabButton/OpenUrlInNewTabButton.tsx @@ -0,0 +1,31 @@ +import { ExternalLink } from 'lucide-react'; +import { ComponentProps, FunctionComponent } from 'react'; +import { buttonVariants } from '@/common/components/atoms/Button/Button'; +import { ctw } from '@/common/utils/ctw/ctw'; + +export const OpenUrlInNewTabButton: FunctionComponent> = ({ + href, + ...props +}) => { + return ( + + + + ); +}; diff --git a/apps/backoffice-v2/src/common/components/organisms/DataTable/DataTable.tsx b/apps/backoffice-v2/src/common/components/organisms/DataTable/DataTable.tsx index 28044fc575..359c9c4b8f 100644 --- a/apps/backoffice-v2/src/common/components/organisms/DataTable/DataTable.tsx +++ b/apps/backoffice-v2/src/common/components/organisms/DataTable/DataTable.tsx @@ -53,7 +53,9 @@ export interface IDataTableProps { // Component props props?: { - scroll?: Partial>; + container?: ComponentProps<'div'>; + scroll?: Omit, 'orientation'> & + Partial, 'orientation'>>; table?: ComponentProps; header?: ComponentProps; head?: ComponentProps; @@ -186,7 +188,13 @@ export const DataTable = ({ }); return ( - + {caption && ( diff --git a/apps/backoffice-v2/src/common/components/organisms/Header/hooks/useNavbarLogic/useNavbarLogic.tsx b/apps/backoffice-v2/src/common/components/organisms/Header/hooks/useNavbarLogic/useNavbarLogic.tsx index a49d6c49b4..79291c41e4 100644 --- a/apps/backoffice-v2/src/common/components/organisms/Header/hooks/useNavbarLogic/useNavbarLogic.tsx +++ b/apps/backoffice-v2/src/common/components/organisms/Header/hooks/useNavbarLogic/useNavbarLogic.tsx @@ -1,8 +1,8 @@ -import { useFiltersQuery } from '@/domains/filters/hooks/queries/useFiltersQuery/useFiltersQuery'; +import { TRouteWithChildren, TRoutes } from '@/Router/types'; import { useFilterId } from '@/common/hooks/useFilterId/useFilterId'; -import { useCallback, useMemo } from 'react'; +import { useFiltersQuery } from '@/domains/filters/hooks/queries/useFiltersQuery/useFiltersQuery'; import { Building, Goal, Users } from 'lucide-react'; -import { TRoutes, TRouteWithChildren } from '@/Router/types'; +import { useCallback, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; export const useNavbarLogic = () => { @@ -20,13 +20,19 @@ export const useNavbarLogic = () => { { text: 'Businesses', icon: , - children: - businessesFilters?.map(({ id, name }) => ({ + children: [ + ...(businessesFilters?.map(({ id, name }) => ({ filterId: id, text: name, href: `/en/case-management/entities?filterId=${id}`, key: `nav-item-${id}`, - })) ?? [], + })) ?? []), + { + text: 'Ongoing Moniotring', + href: `/en/businesses/alerts`, + key: 'nav-item-business-alerts', + }, + ], key: 'nav-item-businesses', }, { diff --git a/apps/backoffice-v2/src/common/hooks/useFilter/useFilter.tsx b/apps/backoffice-v2/src/common/hooks/useFilter/useFilter.tsx index 41438c55d6..167de384b1 100644 --- a/apps/backoffice-v2/src/common/hooks/useFilter/useFilter.tsx +++ b/apps/backoffice-v2/src/common/hooks/useFilter/useFilter.tsx @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; import { useSerializedSearchParams } from '@/common/hooks/useSerializedSearchParams/useSerializedSearchParams'; +import { useCallback } from 'react'; export const useFilter = () => { const [{ filter }, setSearchParams] = useSerializedSearchParams(); @@ -19,8 +19,19 @@ export const useFilter = () => { [filter, setSearchParams], ); + const onClear = useCallback((accessor: string) => { + setSearchParams({ + filter: { + ...filter, + [accessor]: [], + }, + page: '1', + }); + }, []); + return { filter, + onClear, onFilter, }; }; diff --git a/apps/backoffice-v2/src/domains/alerts/fetchers.ts b/apps/backoffice-v2/src/domains/alerts/fetchers.ts index 9d86af7284..a24721bb0f 100644 --- a/apps/backoffice-v2/src/domains/alerts/fetchers.ts +++ b/apps/backoffice-v2/src/domains/alerts/fetchers.ts @@ -1,12 +1,12 @@ -import qs from 'qs'; import { apiClient } from '@/common/api-client/api-client'; import { Method } from '@/common/enums'; -import { z } from 'zod'; -import { ObjectWithIdSchema } from '@/lib/zod/utils/object-with-id/object-with-id'; -import { handleZodError } from '@/common/utils/handle-zod-error/handle-zod-error'; +import { env } from '@/common/env/env'; import { TObjectValues } from '@/common/types'; import { getOriginUrl } from '@/common/utils/get-origin-url/get-url-origin'; -import { env } from '@/common/env/env'; +import { handleZodError } from '@/common/utils/handle-zod-error/handle-zod-error'; +import { ObjectWithIdSchema } from '@/lib/zod/utils/object-with-id/object-with-id'; +import qs from 'qs'; +import { z } from 'zod'; export const AlertSeverity = { CRITICAL: 'critical', @@ -72,32 +72,33 @@ export type TAlertState = (typeof AlertStates)[number]; export type TAlertStates = typeof AlertStates; -export const AlertsListSchema = z.array( - ObjectWithIdSchema.extend({ - dataTimestamp: z.string().datetime(), - updatedAt: z.string().datetime(), - subject: ObjectWithIdSchema.extend({ - name: z.string(), - correlationId: z.string(), - type: z.enum(['business', 'counterparty']), - }), - severity: z.enum(AlertSeverities), +export const AlertSchema = ObjectWithIdSchema.extend({ + dataTimestamp: z.string().datetime(), + updatedAt: z.string().datetime(), + subject: ObjectWithIdSchema.extend({ + name: z.string(), correlationId: z.string(), - alertDetails: z.string(), - // amountOfTxs: z.number(), - assignee: ObjectWithIdSchema.extend({ - fullName: z.string(), - avatarUrl: z.string().nullable().optional(), - }) - .nullable() - .default(null), - status: z.enum(AlertStatuses), - decision: z.enum(AlertStates).nullable().default(null), - counterpartyId: z.string().nullable().default(null), + type: z.enum(['business', 'counterparty']), }), -); + severity: z.enum(AlertSeverities), + correlationId: z.string(), + alertDetails: z.string(), + // amountOfTxs: z.number(), + assignee: ObjectWithIdSchema.extend({ + fullName: z.string(), + avatarUrl: z.string().nullable().optional(), + }) + .nullable() + .default(null), + status: z.enum(AlertStatuses), + decision: z.enum(AlertStates).nullable().default(null), + counterpartyId: z.string().nullable().default(null), +}); + +export const AlertsListSchema = z.array(AlertSchema); -export type TAlertsList = z.output; +export type TAlert = z.output; +export type TAlerts = z.output; export const fetchAlerts = async (params: { orderBy: string; diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/get-alerts-search-schema.ts b/apps/backoffice-v2/src/domains/alerts/helpers/get-alerts-search-schema.ts similarity index 78% rename from apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/get-alerts-search-schema.ts rename to apps/backoffice-v2/src/domains/alerts/helpers/get-alerts-search-schema.ts index 3b212af6d1..07fcaf5d1f 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/get-alerts-search-schema.ts +++ b/apps/backoffice-v2/src/domains/alerts/helpers/get-alerts-search-schema.ts @@ -1,12 +1,13 @@ import { BaseSearchSchema } from '@/common/hooks/useSearchParamsByEntity/validation-schemas'; -import { z } from 'zod'; -import { AlertStatus, AlertStatuses, TAlertsList } from '@/domains/alerts/fetchers'; +import { AlertStatus, AlertStatuses, TAlert } from '@/domains/alerts/fetchers'; import { BooleanishSchema } from '@/lib/zod/utils/checkers'; -export const getAlertsSearchSchema = (authenticatedUserId: string | undefined) => +import { z } from 'zod'; + +export const getAlertsSearchSchema = () => BaseSearchSchema.extend({ sortBy: z .enum(['dataTimestamp', 'status'] as const satisfies ReadonlyArray< - Extract + Extract >) .catch('dataTimestamp'), filter: z @@ -25,4 +26,5 @@ export const getAlertsSearchSchema = (authenticatedUserId: string | undefined) = selected: BooleanishSchema.optional(), businessId: z.string().optional(), counterpartyId: z.string().optional(), + type: z.string().optional(), }); diff --git a/apps/backoffice-v2/src/domains/alerts/hooks/queries/useAlertsQuery/useAlertsQuery.tsx b/apps/backoffice-v2/src/domains/alerts/hooks/queries/useAlertsQuery/useAlertsQuery.tsx index 03c99838dc..441eb38a3e 100644 --- a/apps/backoffice-v2/src/domains/alerts/hooks/queries/useAlertsQuery/useAlertsQuery.tsx +++ b/apps/backoffice-v2/src/domains/alerts/hooks/queries/useAlertsQuery/useAlertsQuery.tsx @@ -1,6 +1,6 @@ +import { alertsQueryKeys } from '@/domains/alerts/query-keys'; import { useIsAuthenticated } from '@/domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated'; import { useQuery } from '@tanstack/react-query'; -import { alertsQueryKeys } from '@/domains/alerts/query-keys'; export const useAlertsQuery = ({ sortBy, diff --git a/apps/backoffice-v2/src/domains/alerts/query-keys.ts b/apps/backoffice-v2/src/domains/alerts/query-keys.ts index 4497cddd74..2972813ccd 100644 --- a/apps/backoffice-v2/src/domains/alerts/query-keys.ts +++ b/apps/backoffice-v2/src/domains/alerts/query-keys.ts @@ -1,12 +1,25 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; import { - fetchAlertDefinitionByAlertId, fetchAlertCorrelationIds, + fetchAlertDefinitionByAlertId, fetchAlerts, } from '@/domains/alerts/fetchers'; export const alertsQueryKeys = createQueryKeys('alerts', { - list: ({ sortBy, sortDir, page, pageSize, ...params }) => { + list: ({ + sortBy, + sortDir, + page, + pageSize, + ...params + }: { + sortBy: string; + sortDir: string; + page: number; + pageSize: number; + search: string; + filter: Record; + }) => { const data = { ...params, orderBy: `${sortBy}:${sortDir}`, diff --git a/apps/backoffice-v2/src/domains/business-alerts/fetchers.ts b/apps/backoffice-v2/src/domains/business-alerts/fetchers.ts new file mode 100644 index 0000000000..6b429cc558 --- /dev/null +++ b/apps/backoffice-v2/src/domains/business-alerts/fetchers.ts @@ -0,0 +1,50 @@ +import { apiClient } from '@/common/api-client/api-client'; +import { Method } from '@/common/enums'; +import { env } from '@/common/env/env'; +import { getOriginUrl } from '@/common/utils/get-origin-url/get-url-origin'; +import { handleZodError } from '@/common/utils/handle-zod-error/handle-zod-error'; +import { AlertSchema } from '@/domains/alerts/fetchers'; +import { ObjectWithIdSchema } from '@/lib/zod/utils/object-with-id/object-with-id'; +import qs from 'qs'; +import { z } from 'zod'; + +export const BusinessAlert = AlertSchema.extend({ + subject: ObjectWithIdSchema.extend({ + companyName: z.string(), + businessReports: z.any(), + }), + additionalInfo: z.object({ + alertReason: z.string(), + businessCompanyName: z.string(), + businessId: z.string(), + previousRiskScore: z.number(), + projectId: z.string(), + reportId: z.string().optional(), + riskScore: z.number(), + severity: z.string(), + }), +}); + +export const BusinessAlertsListSchema = z.array(BusinessAlert); + +export type TBusinessAlert = z.output; + +export type TBusinessAlerts = z.output; + +export const fetchBusinessAlerts = async (params: { + orderBy: string; + page: { + number: number; + size: number; + }; + filter: Record; +}) => { + const queryParams = qs.stringify(params, { encode: false }); + const [alerts, error] = await apiClient({ + url: `${getOriginUrl(env.VITE_API_URL)}/api/v1/external/alerts/business-report?${queryParams}`, + method: Method.GET, + schema: BusinessAlertsListSchema, + }); + + return handleZodError(error, alerts); +}; diff --git a/apps/backoffice-v2/src/domains/business-alerts/hooks/queries/useBusinessAlertsQuery/useBusinessAlertsQuery.tsx b/apps/backoffice-v2/src/domains/business-alerts/hooks/queries/useBusinessAlertsQuery/useBusinessAlertsQuery.tsx new file mode 100644 index 0000000000..07ddf59165 --- /dev/null +++ b/apps/backoffice-v2/src/domains/business-alerts/hooks/queries/useBusinessAlertsQuery/useBusinessAlertsQuery.tsx @@ -0,0 +1,34 @@ +import { useIsAuthenticated } from '@/domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated'; +import { businessAlertsQueryKeys } from '@/domains/business-alerts/query-keys'; +import { useQuery } from '@tanstack/react-query'; + +export const useBusinessAlertsQuery = ({ + sortBy, + sortDir, + page, + pageSize, + search, + filter, +}: { + sortBy: string; + sortDir: string; + page: number; + pageSize: number; + search: string; + filter: Record; +}) => { + const isAuthenticated = useIsAuthenticated(); + + return useQuery({ + ...businessAlertsQueryKeys.list({ + filter, + sortBy, + sortDir, + page, + pageSize, + search, + }), + enabled: isAuthenticated && !!sortBy && !!sortDir && !!page && !!pageSize, + staleTime: 100_000, + }); +}; diff --git a/apps/backoffice-v2/src/domains/business-alerts/query-keys.ts b/apps/backoffice-v2/src/domains/business-alerts/query-keys.ts new file mode 100644 index 0000000000..d3be2aa66e --- /dev/null +++ b/apps/backoffice-v2/src/domains/business-alerts/query-keys.ts @@ -0,0 +1,41 @@ +import { fetchBusinessAlerts } from '@/domains/business-alerts/fetchers'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const businessAlertsQueryKeys = createQueryKeys('alerts', { + list: ({ + sortBy, + sortDir, + page, + pageSize, + ...params + }: { + sortBy: string; + sortDir: string; + page: number; + pageSize: number; + search: string; + filter: Record; + }) => { + const data = { + ...params, + orderBy: `${sortBy}:${sortDir}`, + page: { + number: Number(page), + size: Number(pageSize), + }, + }; + + return { + queryKey: [ + { + ...params, + sortBy, + sortDir, + page, + pageSize, + }, + ], + queryFn: () => fetchBusinessAlerts(data), + }; + }, +}); diff --git a/apps/backoffice-v2/src/domains/business-reports/fetchers.ts b/apps/backoffice-v2/src/domains/business-reports/fetchers.ts index aa6b517cb6..28cc5ace6f 100644 --- a/apps/backoffice-v2/src/domains/business-reports/fetchers.ts +++ b/apps/backoffice-v2/src/domains/business-reports/fetchers.ts @@ -1,28 +1,52 @@ -import { z } from 'zod'; 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 BusinessReportSchema = z .object({ + riskScore: z.number(), report: z.object({ reportFileId: z.string(), + reportId: z.string(), }), + createdAt: z.string(), }) .optional(); -export const fetchBusinessReports = async ({ +export type TBusinessReport = z.infer; + +export type TBusinessReportType = ('MERCHANT_REPORT_T1' | 'ONGOING_MERCHANT_REPORT_T1') & + (string & {}); + +export const fetchLatestBusinessReport = async ({ businessId, reportType, }: { businessId: string; - reportType: 'MERCHANT_REPORT_T1' & (string & {}); + reportType: TBusinessReportType; }) => { - const [filter, error] = await apiClient({ + const [businessReports, error] = await apiClient({ endpoint: `business-reports/latest?businessId=${businessId}&type=${reportType}`, method: Method.GET, schema: BusinessReportSchema, }); - return handleZodError(error, filter); + return handleZodError(error, businessReports); +}; + +export const fetchBusinessReports = async ({ + businessId, + reportType, +}: { + businessId: string; + reportType: TBusinessReportType; +}) => { + const [businessReports, error] = await apiClient({ + endpoint: `business-reports/?businessId=${businessId}&type=${reportType}`, + method: Method.GET, + schema: z.array(BusinessReportSchema), + }); + + return handleZodError(error, businessReports); }; diff --git a/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useBusinessReportsQuery/useBusinessReportsQuery.ts b/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useBusinessReportsQuery/useBusinessReportsQuery.ts new file mode 100644 index 0000000000..e870d1402c --- /dev/null +++ b/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useBusinessReportsQuery/useBusinessReportsQuery.ts @@ -0,0 +1,20 @@ +import { useIsAuthenticated } from '@/domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated'; +import { businessReportsQueryKey } from '@/domains/business-reports/query-keys'; +import { useQuery } from '@tanstack/react-query'; +import { TBusinessReportType } from '@/domains/business-reports/fetchers'; + +export const useBusinessReportsQuery = ({ + businessId, + reportType, +}: { + businessId: string; + reportType: TBusinessReportType; +}) => { + const isAuthenticated = useIsAuthenticated(); + + return useQuery({ + ...businessReportsQueryKey.list({ businessId, reportType }), + enabled: isAuthenticated, + staleTime: 100_000, + }); +}; diff --git a/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useLatestBusinessReportQuery/useLatestBusinessReportQuery.tsx b/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useLatestBusinessReportQuery/useLatestBusinessReportQuery.tsx index 592804ef4d..a19bb394b5 100644 --- a/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useLatestBusinessReportQuery/useLatestBusinessReportQuery.tsx +++ b/apps/backoffice-v2/src/domains/business-reports/hooks/queries/useLatestBusinessReportQuery/useLatestBusinessReportQuery.tsx @@ -2,13 +2,14 @@ import { useIsAuthenticated } from '@/domains/auth/context/AuthProvider/hooks/us import { useQuery } from '@tanstack/react-query'; import { isString } from '@/common/utils/is-string/is-string'; import { businessReportsQueryKey } from '@/domains/business-reports/query-keys'; +import { TBusinessReportType } from '@/domains/business-reports/fetchers'; export const useLatestBusinessReportQuery = ({ businessId, reportType, }: { businessId: string; - reportType: 'MERCHANT_REPORT_T1' & (string & {}); + reportType: TBusinessReportType; }) => { const isAuthenticated = useIsAuthenticated(); diff --git a/apps/backoffice-v2/src/domains/business-reports/query-keys.ts b/apps/backoffice-v2/src/domains/business-reports/query-keys.ts index 815069e76a..4fa692cafd 100644 --- a/apps/backoffice-v2/src/domains/business-reports/query-keys.ts +++ b/apps/backoffice-v2/src/domains/business-reports/query-keys.ts @@ -1,6 +1,10 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { fetchBusinessReports } from '@/domains/business-reports/fetchers'; +import { + fetchBusinessReports, + fetchLatestBusinessReport, + TBusinessReportType, +} from '@/domains/business-reports/fetchers'; export const businessReportsQueryKey = createQueryKeys('business-reports', { latest: ({ @@ -8,8 +12,12 @@ export const businessReportsQueryKey = createQueryKeys('business-reports', { reportType, }: { businessId: string; - reportType: 'MERCHANT_REPORT_T1' & (string & {}); + reportType: TBusinessReportType; }) => ({ + queryKey: [{ businessId, reportType }], + queryFn: () => fetchLatestBusinessReport({ businessId, reportType }), + }), + list: ({ businessId, reportType }: { businessId: string; reportType: TBusinessReportType }) => ({ queryKey: [{ businessId, reportType }], queryFn: () => fetchBusinessReports({ businessId, reportType }), }), diff --git a/apps/backoffice-v2/src/pages/Businesses/Businesses.tsx b/apps/backoffice-v2/src/pages/Businesses/Businesses.tsx new file mode 100644 index 0000000000..c32c479a84 --- /dev/null +++ b/apps/backoffice-v2/src/pages/Businesses/Businesses.tsx @@ -0,0 +1,6 @@ +import { FunctionComponent } from 'react'; +import { Outlet } from 'react-router-dom'; + +export const Businesses: FunctionComponent = () => { + return ; +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/BusinessesAlerts.page.tsx b/apps/backoffice-v2/src/pages/BusinessesAlerts/BusinessesAlerts.page.tsx new file mode 100644 index 0000000000..64f6cbe0b5 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/BusinessesAlerts.page.tsx @@ -0,0 +1,55 @@ +import { BusinessAlertsTable } from '@/pages/BusinessesAlerts/components/BusinessAlertsTable'; +import { useBusinessAlertsLogic } from '@/pages/BusinessesAlerts/hooks/useBusinessAlertsLogic/useBusinessAlertsLogic'; +import { AlertsHeader } from '@/pages/TransactionMonitoringAlerts/components/AlertsHeader'; +import { AlertsPagination } from '@/pages/TransactionMonitoringAlerts/components/AlertsPagination/AlertsPagination'; +import { NoAlerts } from '@/pages/TransactionMonitoringAlerts/components/NoAlerts/NoAlerts'; +import { isNonEmptyArray } from '@ballerine/common'; +import { Outlet } from 'react-router-dom'; + +export const BusinessesAlerts = () => { + const { + alerts, + isLoadingAlerts, + assignees, + authenticatedUser, + page, + correlationIds, + onPrevPage, + onNextPage, + onPaginate, + isLastPage, + search, + onSearch, + } = useBusinessAlertsLogic(); + + return ( + + Businesses Ongoing Monitoring + + + + + + {isNonEmptyArray(alerts) && } + {Array.isArray(alerts) && !alerts.length && !isLoadingAlerts && } + + + + + + + + ); +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/BusinessAlertsTable.tsx b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/BusinessAlertsTable.tsx new file mode 100644 index 0000000000..3a93ceb6f3 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/BusinessAlertsTable.tsx @@ -0,0 +1,22 @@ +import { DataTable } from '@/common/components/organisms/DataTable/DataTable'; +import { useBusinessAlertsTableLogic } from '@/pages/BusinessesAlerts/components/BusinessAlertsTable/hooks/useBusinessAlertsTableLogic/useBusinessAlertsTableLogic'; +import { IAlertsTableProps } from '@/pages/TransactionMonitoringAlerts/components/AlertsTable/interfaces'; +import { FunctionComponent } from 'react'; +import { columns } from './columns'; + +export const BusinessAlertsTable: FunctionComponent = ({ data }) => { + const { Cell } = useBusinessAlertsTableLogic({ data }); + + return ( + + ); +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/columns.tsx b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/columns.tsx new file mode 100644 index 0000000000..916ed71216 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/columns.tsx @@ -0,0 +1,182 @@ +import { AvatarFallback } from '@/common/components/atoms/Avatar_/Avatar.Fallback'; +import { AvatarImage } from '@/common/components/atoms/Avatar_/Avatar.Image'; +import { Avatar } from '@/common/components/atoms/Avatar_/Avatar_'; +import { IndeterminateCheckbox } from '@/common/components/atoms/IndeterminateCheckbox/IndeterminateCheckbox'; +import { TextWithNAFallback } from '@/common/components/atoms/TextWithNAFallback/TextWithNAFallback'; +import { createInitials } from '@/common/utils/create-initials/create-initials'; +import { ctw } from '@/common/utils/ctw/ctw'; +import { valueOrNA } from '@/common/utils/value-or-na/value-or-na'; +import { TBusinessAlert } from '@/domains/business-alerts/fetchers'; +import { getSeverityFromRiskScore } from '@/pages/BusinessesAlerts/components/BusinessAlertsTable/utils/get-severity-from-risk-score'; +import { + severityToClassName, + severityToTextClassName, +} from '@/pages/TransactionMonitoringAlerts/components/AlertsTable/severity-to-class-name'; +import { Badge } from '@ballerine/ui'; +import { createColumnHelper } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import { UserCircle2 } from 'lucide-react'; +import { ComponentProps } from 'react'; +import { titleCase } from 'string-ts'; + +const columnHelper = createColumnHelper< + TBusinessAlert & { + decision: string; + } +>(); + +export const columns = [ + columnHelper.accessor('dataTimestamp', { + cell: info => { + const dataTimestamp = info.getValue(); + + if (!dataTimestamp) { + return {dataTimestamp}; + } + + const date = dayjs(dataTimestamp).format('MMM DD, YYYY'); + const time = dayjs(dataTimestamp).format('hh:mm'); + + return ( + + {date} + {time} + + ); + }, + header: 'Created At', + }), + columnHelper.accessor('additionalInfo.businessCompanyName', { + cell: info => { + const businessCompanyName = info.getValue(); + + return ( + {valueOrNA(businessCompanyName)} + ); + }, + header: 'Business', + }), + columnHelper.accessor('additionalInfo.alertReason', { + cell: info => { + const alertReason = info.getValue(); + + return {alertReason}; + }, + header: 'Reason', + }), + columnHelper.accessor('additionalInfo.severity', { + cell: info => { + const severity = info.getValue(); + + return ( + + {titleCase(severity ?? '')} + + ); + }, + header: 'Severity', + }), + columnHelper.accessor('additionalInfo.riskScore', { + cell: info => { + const riskScore = info.getValue(); + const severity = getSeverityFromRiskScore(riskScore); + + return ( + + + {riskScore} + + + {titleCase(severity ?? '')} + + + ); + }, + header: 'Report Risk Score', + }), + columnHelper.accessor('assignee', { + cell: info => { + const assignee = info.getValue(); + + return ( + + {!assignee && } + {assignee && ( + + + + {createInitials(assignee?.fullName)} + + + )} + {assignee?.fullName} + + ); + }, + header: 'Assignee', + }), + columnHelper.accessor('status', { + cell: info => { + const status = info.getValue(); + + return ( + {titleCase(status)} + ); + }, + header: 'Status', + }), + columnHelper.display({ + id: 'select', + cell: ({ row }) => { + return ( + )} + className={'border-[#E5E7EB]'} + /> + ); + }, + header: ({ table }) => { + return ( + + table.getToggleAllRowsSelectedHandler()({ + target: { checked }, + }), + } satisfies ComponentProps)} + className={'border-[#E5E7EB]'} + /> + ); + }, + }), +]; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/hooks/useBusinessAlertsTableLogic/useBusinessAlertsTableLogic.tsx b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/hooks/useBusinessAlertsTableLogic/useBusinessAlertsTableLogic.tsx new file mode 100644 index 0000000000..785ca5dcb5 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/hooks/useBusinessAlertsTableLogic/useBusinessAlertsTableLogic.tsx @@ -0,0 +1,37 @@ +import { useLocale } from '@/common/hooks/useLocale/useLocale'; + +import { IDataTableProps } from '@/common/components/organisms/DataTable/DataTable'; +import { TBusinessAlerts } from '@/domains/business-alerts/fetchers'; +import { useCallback } from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +export const useBusinessAlertsTableLogic = ({ data }: { data: TBusinessAlerts }) => { + const locale = useLocale(); + const { pathname, search } = useLocation(); + + const onClick = useCallback(() => { + sessionStorage.setItem( + 'business-transaction-monitoring:transactions-drawer:previous-path', + `${pathname}${search}`, + ); + }, [pathname, search]); + + const Cell: IDataTableProps['CellContentWrapper'] = ({ cell, children }) => { + const itemId = cell.id.replace(`_${cell.column.id}`, ''); + const item = data.find(item => item.id === itemId); + + return ( + + {children} + + ); + }; + + return { Cell }; +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/index.ts b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/index.ts new file mode 100644 index 0000000000..7e703655f8 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/index.ts @@ -0,0 +1 @@ +export * from './BusinessAlertsTable'; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/interfaces.ts b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/interfaces.ts new file mode 100644 index 0000000000..2ed0705d72 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/interfaces.ts @@ -0,0 +1,5 @@ +import { TBusinessAlerts } from '@/domains/business-alerts/fetchers'; + +export interface IAlertsTableProps { + data: TBusinessAlerts; +} diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/utils/get-severity-from-risk-score.ts b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/utils/get-severity-from-risk-score.ts new file mode 100644 index 0000000000..7b83376b98 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/components/BusinessAlertsTable/utils/get-severity-from-risk-score.ts @@ -0,0 +1,17 @@ +import { AlertSeverity, TAlertSeverity } from '@/domains/alerts/fetchers'; + +export const getSeverityFromRiskScore = (riskScore: number): TAlertSeverity => { + if (riskScore <= 39) { + return AlertSeverity.LOW; + } + + if (riskScore <= 69) { + return AlertSeverity.MEDIUM; + } + + if (riskScore <= 84) { + return AlertSeverity.HIGH; + } + + return AlertSeverity.HIGH; +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlerts/hooks/useBusinessAlertsLogic/useBusinessAlertsLogic.tsx b/apps/backoffice-v2/src/pages/BusinessesAlerts/hooks/useBusinessAlertsLogic/useBusinessAlertsLogic.tsx new file mode 100644 index 0000000000..38c956e30e --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlerts/hooks/useBusinessAlertsLogic/useBusinessAlertsLogic.tsx @@ -0,0 +1,56 @@ +import { usePagination } from '@/common/hooks/usePagination/usePagination'; +import { useSearch } from '@/common/hooks/useSearch/useSearch'; +import { useZodSearchParams } from '@/common/hooks/useZodSearchParams/useZodSearchParams'; +import { getAlertsSearchSchema } from '@/domains/alerts/helpers/get-alerts-search-schema'; +import { useAlertCorrelationIdsQuery } from '@/domains/alerts/hooks/queries/useAlertLabelsQuery/useAlertLabelsQuery'; +import { useAuthenticatedUserQuery } from '@/domains/auth/hooks/queries/useAuthenticatedUserQuery/useAuthenticatedUserQuery'; +import { useBusinessAlertsQuery } from '@/domains/business-alerts/hooks/queries/useBusinessAlertsQuery/useBusinessAlertsQuery'; +import { useUsersQuery } from '@/domains/users/hooks/queries/useUsersQuery/useUsersQuery'; +import { useMemo } from 'react'; + +export const useBusinessAlertsLogic = () => { + const { data: session } = useAuthenticatedUserQuery(); + const AlertsSearchSchema = getAlertsSearchSchema(); + const [{ filter, sortBy, sortDir, page, pageSize, search: searchValue }] = + useZodSearchParams(AlertsSearchSchema); + const { data: alerts, isLoading: isLoadingAlerts } = useBusinessAlertsQuery({ + filter, + page, + pageSize, + search: searchValue, + sortDir, + sortBy, + }); + const { data: correlationIds } = useAlertCorrelationIdsQuery(); + const { data: assignees } = useUsersQuery(); + const sortedAssignees = useMemo( + () => + // Sort assignees so that the authenticated user is always first + assignees + ?.slice() + ?.sort((a, b) => (a?.id === session?.user?.id ? -1 : b?.id === session?.user?.id ? 1 : 0)), + [assignees, session?.user?.id], + ); + + const { onPaginate, onPrevPage, onNextPage } = usePagination(); + const isLastPage = (alerts?.length ?? 0) < pageSize || alerts?.length === 0; + const { search, onSearch } = useSearch({ + initialSearch: searchValue, + }); + + return { + alerts, + isLoadingAlerts, + assignees: sortedAssignees, + authenticatedUser: session?.user, + page, + pageSize, + correlationIds, + onPrevPage, + onNextPage, + onPaginate, + isLastPage, + search, + onSearch, + }; +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/BusinessesAlertsAnalysis.page.tsx b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/BusinessesAlertsAnalysis.page.tsx new file mode 100644 index 0000000000..24b96bbe97 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/BusinessesAlertsAnalysis.page.tsx @@ -0,0 +1,13 @@ +import { OngoingMonitoringRiskSheet } from '@/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringRiskSheet'; +import { useBusinessAlertsAnalysisLogic } from '@/pages/BusinessesAlertsAnalysis/hooks/useBusinessAlertsAnalysisLogic/useBusinessAlertsAnalysisLogic'; + +export const BusinessesAlertsAnalysisPage = () => { + const { businessReports, onNavigateBack } = useBusinessAlertsAnalysisLogic(); + + return ( + + ); +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringRiskSheet/OngoingMonitoringRiskSheet.tsx b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringRiskSheet/OngoingMonitoringRiskSheet.tsx new file mode 100644 index 0000000000..3fa11bb0cf --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringRiskSheet/OngoingMonitoringRiskSheet.tsx @@ -0,0 +1,38 @@ +import { SheetContent } from '@/common/components/atoms/Sheet'; +import { Sheet } from '@/common/components/atoms/Sheet/Sheet'; +import { TBusinessReport } from '@/domains/business-reports/fetchers'; +import { OngoingMonitoringTable } from '@/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable'; +import { FunctionComponent } from 'react'; + +export interface IOngoingMonitoringRiskSheet { + onOpenStateChange: () => void; + businessReports: TBusinessReport[]; +} + +export const OngoingMonitoringRiskSheet: FunctionComponent = ({ + onOpenStateChange, + businessReports, +}) => { + return ( + + + + + Ongoing monitoring risk change + + Summary + + The ongoing monitoring has detected new violations on the merchant, that have raised + its risk score. + Please review the violations and resolve.{' '} + + + + + + + + + + ); +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringRiskSheet/index.ts b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringRiskSheet/index.ts new file mode 100644 index 0000000000..3126812093 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringRiskSheet/index.ts @@ -0,0 +1 @@ +export * from './OngoingMonitoringRiskSheet'; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/OngoingMonitoringTable.tsx b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/OngoingMonitoringTable.tsx new file mode 100644 index 0000000000..db972e7926 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/OngoingMonitoringTable.tsx @@ -0,0 +1,27 @@ +import React, { FunctionComponent } from 'react'; +import { columns } from './columns'; + +import { TBusinessReport } from '@/domains/business-reports/fetchers'; +import { DataTable } from '@/common/components/organisms/DataTable/DataTable'; + +export const OngoingMonitoringTable: FunctionComponent<{ + businessReports: TBusinessReport[]; +}> = ({ businessReports }) => { + return ( + + ); +}; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/columns.tsx b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/columns.tsx new file mode 100644 index 0000000000..c451e4dfa4 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/columns.tsx @@ -0,0 +1,80 @@ +import { TextWithNAFallback } from '@/common/components/atoms/TextWithNAFallback/TextWithNAFallback'; +import { ctw } from '@/common/utils/ctw/ctw'; +import { TBusinessReport } from '@/domains/business-reports/fetchers'; +import { getSeverityFromRiskScore } from '@/pages/BusinessesAlerts/components/BusinessAlertsTable/utils/get-severity-from-risk-score'; +import { OpenUrlInNewTabButton } from '@/common/components/atoms/OpenUrlInNewTabButton/OpenUrlInNewTabButton'; +import { + severityToClassName, + severityToTextClassName, +} from '@/pages/TransactionMonitoringAlerts/components/AlertsTable/severity-to-class-name'; +import { Badge } from '@ballerine/ui'; +import { createColumnHelper } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import { titleCase } from 'string-ts'; +import { useStorageFileByIdQuery } from '@/domains/storage/hooks/queries/useStorageFileByIdQuery/useStorageFileByIdQuery'; + +const columnHelper = createColumnHelper(); + +export const columns = [ + columnHelper.accessor('createdAt', { + cell: info => { + const dateValue = info.getValue(); + const date = dayjs(dateValue).format('MMM DD, YYYY'); + const time = dayjs(dateValue).format('hh:mm'); + + return ( + + {date} + {time} + + ); + }, + header: 'Date & Time', + }), + columnHelper.accessor('riskScore', { + cell: info => { + const riskScore = info.getValue(); + const severity = getSeverityFromRiskScore(riskScore); + + return ( + + + {riskScore} + + + {titleCase(severity ?? '')} + + + ); + }, + header: 'Risk Score', + }), + columnHelper.accessor('report.reportFileId', { + cell: info => { + const reportFileId = info.getValue(); + // eslint-disable-next-line react-hooks/rules-of-hooks -- ESLint doesn't like `cell` not being `Cell`. + const { data: signedUrl } = useStorageFileByIdQuery(reportFileId ?? '', { + isEnabled: !!reportFileId, + withSignedUrl: true, + }); + + return ; + }, + header: '', + }), +]; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/index.ts b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/index.ts new file mode 100644 index 0000000000..2ece945b83 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/components/OngoingMonitoringTable/index.ts @@ -0,0 +1 @@ +export * from './OngoingMonitoringTable'; diff --git a/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/hooks/useBusinessAlertsAnalysisLogic/useBusinessAlertsAnalysisLogic.tsx b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/hooks/useBusinessAlertsAnalysisLogic/useBusinessAlertsAnalysisLogic.tsx new file mode 100644 index 0000000000..211bd43f62 --- /dev/null +++ b/apps/backoffice-v2/src/pages/BusinessesAlertsAnalysis/hooks/useBusinessAlertsAnalysisLogic/useBusinessAlertsAnalysisLogic.tsx @@ -0,0 +1,33 @@ +import { useSerializedSearchParams } from '@/common/hooks/useSerializedSearchParams/useSerializedSearchParams'; +import { useBusinessReportsQuery } from '@/domains/business-reports/hooks/queries/useBusinessReportsQuery/useBusinessReportsQuery'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export const useBusinessAlertsAnalysisLogic = () => { + const [{ businessId }] = useSerializedSearchParams(); + const { data: businessReports = [] } = useBusinessReportsQuery({ + businessId: (businessId as string) ?? '', + reportType: 'ONGOING_MERCHANT_REPORT_T1', + }); + const navigate = useNavigate(); + const onNavigateBack = useCallback(() => { + const previousPath = sessionStorage.getItem( + 'business-transaction-monitoring:transactions-drawer:previous-path', + ); + + if (!previousPath) { + navigate('../'); + + return; + } + + navigate(previousPath); + + sessionStorage.removeItem('business-transaction-monitoring:transactions-drawer:previous-path'); + }, [navigate]); + + return { + businessReports, + onNavigateBack, + }; +}; diff --git a/apps/backoffice-v2/src/pages/SignIn/SignIn.page.tsx b/apps/backoffice-v2/src/pages/SignIn/SignIn.page.tsx index 2390fd8e0b..0d055e66dd 100644 --- a/apps/backoffice-v2/src/pages/SignIn/SignIn.page.tsx +++ b/apps/backoffice-v2/src/pages/SignIn/SignIn.page.tsx @@ -1,26 +1,26 @@ -import { z } from 'zod'; import { isErrorWithCode } from '@ballerine/common'; -import { SubmitHandler, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Form } from '../../common/components/organisms/Form/Form'; +import { FunctionComponent, useCallback } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; import { Button } from '../../common/components/atoms/Button/Button'; -import { Input } from '../../common/components/atoms/Input/Input'; import { Card } from '../../common/components/atoms/Card/Card'; -import { BallerineLogo } from '../../common/components/atoms/icons'; -import { useSignInMutation } from '../../domains/auth/hooks/mutations/useSignInMutation/useSignInMutation'; -import { FunctionComponent, useCallback } from 'react'; -import { useAuthContext } from '../../domains/auth/context/AuthProvider/hooks/useAuthContext/useAuthContext'; -import { useIsAuthenticated } from '../../domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated'; -import { CardHeader } from '../../common/components/atoms/Card/Card.Header'; import { CardContent } from '../../common/components/atoms/Card/Card.Content'; +import { CardHeader } from '../../common/components/atoms/Card/Card.Header'; +import { ErrorAlert } from '../../common/components/atoms/ErrorAlert/ErrorAlert'; +import { Input } from '../../common/components/atoms/Input/Input'; +import { BallerineLogo } from '../../common/components/atoms/icons'; +import { FullScreenLoader } from '../../common/components/molecules/FullScreenLoader/FullScreenLoader'; +import { Form } from '../../common/components/organisms/Form/Form'; +import { FormControl } from '../../common/components/organisms/Form/Form.Control'; import { FormField } from '../../common/components/organisms/Form/Form.Field'; import { FormItem } from '../../common/components/organisms/Form/Form.Item'; import { FormLabel } from '../../common/components/organisms/Form/Form.Label'; -import { FormControl } from '../../common/components/organisms/Form/Form.Control'; import { FormMessage } from '../../common/components/organisms/Form/Form.Message'; import { env } from '../../common/env/env'; -import { ErrorAlert } from '../../common/components/atoms/ErrorAlert/ErrorAlert'; -import { FullScreenLoader } from '../../common/components/molecules/FullScreenLoader/FullScreenLoader'; +import { useAuthContext } from '../../domains/auth/context/AuthProvider/hooks/useAuthContext/useAuthContext'; +import { useIsAuthenticated } from '../../domains/auth/context/AuthProvider/hooks/useIsAuthenticated/useIsAuthenticated'; +import { useSignInMutation } from '../../domains/auth/hooks/mutations/useSignInMutation/useSignInMutation'; export const SignIn: FunctionComponent = () => { const SignInSchema = z.object({ @@ -55,6 +55,7 @@ export const SignIn: FunctionComponent = () => { }); // Handles a flash of content on sign in + if (isAuthenticated) return ; return ( diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/TransactionMonitoringAlerts.page.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/TransactionMonitoringAlerts.page.tsx index aaf527ee38..c7e9b4cf5e 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/TransactionMonitoringAlerts.page.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/TransactionMonitoringAlerts.page.tsx @@ -1,10 +1,10 @@ -import { AlertsTable } from '@/pages/TransactionMonitoringAlerts/components/AlertsTable'; -import { AlertsHeader } from 'src/pages/TransactionMonitoringAlerts/components/AlertsHeader'; import { AlertsPagination } from '@/pages/TransactionMonitoringAlerts/components/AlertsPagination/AlertsPagination'; +import { AlertsTable } from '@/pages/TransactionMonitoringAlerts/components/AlertsTable'; +import { NoAlerts } from '@/pages/TransactionMonitoringAlerts/components/NoAlerts/NoAlerts'; import { useTransactionMonitoringAlertsLogic } from '@/pages/TransactionMonitoringAlerts/hooks/useTransactionMonitoringAlertsLogic/useTransactionMonitoringAlertsLogic'; -import { Outlet } from 'react-router-dom'; import { isNonEmptyArray } from '@ballerine/common'; -import { NoAlerts } from '@/pages/TransactionMonitoringAlerts/components/NoAlerts/NoAlerts'; +import { Outlet } from 'react-router-dom'; +import { AlertsHeader } from 'src/pages/TransactionMonitoringAlerts/components/AlertsHeader'; export const TransactionMonitoringAlerts = () => { const { diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsFilters/AlertsFilters.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsFilters/AlertsFilters.tsx index 9258e26d9d..a1a56c121e 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsFilters/AlertsFilters.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsFilters/AlertsFilters.tsx @@ -1,10 +1,10 @@ -import { FunctionComponent, useCallback, useMemo } from 'react'; -import { TUsers } from '@/domains/users/types'; import { MultiSelect } from '@/common/components/atoms/MultiSelect/MultiSelect'; import { useFilter } from '@/common/hooks/useFilter/useFilter'; +import { keyFactory } from '@/common/utils/key-factory/key-factory'; import { AlertStatuses } from '@/domains/alerts/fetchers'; +import { TUsers } from '@/domains/users/types'; +import { FunctionComponent, useCallback, useMemo } from 'react'; import { titleCase } from 'string-ts'; -import { keyFactory } from '@/common/utils/key-factory/key-factory'; export const AlertsFilters: FunctionComponent<{ assignees: TUsers; @@ -50,10 +50,10 @@ export const AlertsFilters: FunctionComponent<{ accessor: 'correlationIds', options: useMemo( () => - correlationIds.map(label => ({ + correlationIds?.map(label => ({ label, value: label, - })), + })) ?? [], [correlationIds], ), }, diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsHeader/AlertsHeader.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsHeader/AlertsHeader.tsx index ffd9d647bf..b614877255 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsHeader/AlertsHeader.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsHeader/AlertsHeader.tsx @@ -1,17 +1,17 @@ -import { Search } from '@/pages/TransactionMonitoringAlerts/components/Search'; -import { AlertsFilters } from 'src/pages/TransactionMonitoringAlerts/components/AlertsFilters'; -import React, { ComponentProps, FunctionComponent, useCallback } from 'react'; -import { TUsers } from '@/domains/users/types'; import { useSelect } from '@/common/hooks/useSelect/useSelect'; +import { TObjectValues } from '@/common/types'; +import { toScreamingSnakeCase } from '@/common/utils/to-screaming-snake-case/to-screaming-snake-case'; +import { AlertStates, alertDecisionToState, alertStateToDecision } from '@/domains/alerts/fetchers'; +import { useAlertsDecisionByIdsMutation } from '@/domains/alerts/hooks/mutations/useAlertsDecisionByIdsMutation/useAlertsDecisionByIdsMutation'; import { useAssignAlertsByIdsMutation } from '@/domains/alerts/hooks/mutations/useAssignAlertsMutation/useAssignAlertsMutation'; +import { TUsers } from '@/domains/users/types'; import { AlertsAssignDropdown } from '@/pages/TransactionMonitoringAlerts/components/AlertsAssignDropdown/AlertsAssignDropdown'; -import { alertDecisionToState, AlertStates, alertStateToDecision } from '@/domains/alerts/fetchers'; -import { capitalize, lowerCase } from 'string-ts'; -import { useAlertsDecisionByIdsMutation } from '@/domains/alerts/hooks/mutations/useAlertsDecisionByIdsMutation/useAlertsDecisionByIdsMutation'; -import { toScreamingSnakeCase } from '@/common/utils/to-screaming-snake-case/to-screaming-snake-case'; import { AlertsDecisionDropdown } from '@/pages/TransactionMonitoringAlerts/components/AlertsDecisionDropdown/AlertsDecisionDropdown'; +import { Search } from '@/pages/TransactionMonitoringAlerts/components/Search'; import { COMING_SOON_ALERT_DECISIONS } from '@/pages/TransactionMonitoringAlerts/constants'; -import { TObjectValues } from '@/common/types'; +import { ComponentProps, FunctionComponent, useCallback } from 'react'; +import { AlertsFilters } from 'src/pages/TransactionMonitoringAlerts/components/AlertsFilters'; +import { capitalize, lowerCase } from 'string-ts'; export const decisionToClassName = { [lowerCase(alertStateToDecision.REJECTED)]: 'text-destructive', diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/AlertsTable.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/AlertsTable.tsx index 4e21e76d77..1492e4f747 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/AlertsTable.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/AlertsTable.tsx @@ -1,8 +1,8 @@ -import React, { FunctionComponent } from 'react'; -import { IAlertsTableProps } from '@/pages/TransactionMonitoringAlerts/components/AlertsTable/interfaces'; import { DataTable } from '@/common/components/organisms/DataTable/DataTable'; -import { columns } from './columns'; import { useAlertsTableLogic } from '@/pages/TransactionMonitoringAlerts/components/AlertsTable/hooks/useAlertsTableLogic'; +import { IAlertsTableProps } from '@/pages/TransactionMonitoringAlerts/components/AlertsTable/interfaces'; +import { FunctionComponent } from 'react'; +import { columns } from './columns'; export const AlertsTable: FunctionComponent = ({ data }) => { const { Cell } = useAlertsTableLogic({ data }); diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/columns.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/columns.tsx index d6407da0f6..40ed510828 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/columns.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/columns.tsx @@ -1,5 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { TAlertsList, TAlertState } from '@/domains/alerts/fetchers'; +import { TAlert, TAlertState } from '@/domains/alerts/fetchers'; import { TextWithNAFallback } from '@/common/components/atoms/TextWithNAFallback/TextWithNAFallback'; import dayjs from 'dayjs'; import { Badge } from '@ballerine/ui'; @@ -18,7 +18,7 @@ import { useEllipsesWithTitle } from '@/common/hooks/useEllipsesWithTitle/useEll import { buttonVariants } from '@/common/components/atoms/Button/Button'; const columnHelper = createColumnHelper< - TAlertsList[number] & { + TAlert & { // TODO: Change type once decisions PR is merged // Computed from `alert.state` decision: string; diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/hooks/useAlertsTableLogic.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/hooks/useAlertsTableLogic.tsx index 30f0f0e641..6b5946c2fa 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/hooks/useAlertsTableLogic.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/hooks/useAlertsTableLogic.tsx @@ -2,10 +2,10 @@ import { useLocale } from '@/common/hooks/useLocale/useLocale'; import { Link, useLocation } from 'react-router-dom'; import React, { useCallback } from 'react'; import { IDataTableProps } from '@/common/components/organisms/DataTable/DataTable'; -import { TAlertsList } from '@/domains/alerts/fetchers'; +import { TAlerts } from '@/domains/alerts/fetchers'; interface IUseAlertsTableLogic { - data: TAlertsList; + data: TAlerts; } export const useAlertsTableLogic = ({ data }: IUseAlertsTableLogic) => { diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/interfaces.ts b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/interfaces.ts index 21ee1962d3..2ed0705d72 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/interfaces.ts +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/interfaces.ts @@ -1,5 +1,5 @@ -import { TAlertsList } from '@/domains/alerts/fetchers'; +import { TBusinessAlerts } from '@/domains/business-alerts/fetchers'; export interface IAlertsTableProps { - data: TAlertsList; + data: TBusinessAlerts; } diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/severity-to-class-name.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/severity-to-class-name.tsx index 5b089a6701..45680b4537 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/severity-to-class-name.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/components/AlertsTable/severity-to-class-name.tsx @@ -1,13 +1,24 @@ -import { ComponentProps } from 'react'; import { Badge } from '@ballerine/ui'; +import { ComponentProps } from 'react'; +import { TAlertSeverity } from '@/domains/alerts/fetchers'; -export const severityToClassName = { - HIGH: 'bg-destructive/20 text-destructive', - MEDIUM: 'bg-orange-100 text-orange-300', - LOW: 'bg-success/20 text-success', - CRITICAL: 'bg-destructive text-background', - DEFAULT: 'bg-foreground text-background', -} as const satisfies Record< - 'HIGH' | 'MEDIUM' | 'LOW' | 'CRITICAL' | 'DEFAULT', +type SeverityToClassName = Record< + Uppercase | 'DEFAULT', ComponentProps['className'] >; + +export const severityToTextClassName = { + HIGH: 'text-destructive', + MEDIUM: 'text-orange-300', + LOW: 'text-success', + CRITICAL: 'text-background', + DEFAULT: 'text-background', +} as const satisfies SeverityToClassName; + +export const severityToClassName = { + HIGH: `bg-destructive/20 ${severityToTextClassName.HIGH}`, + MEDIUM: `bg-orange-100 ${severityToTextClassName.MEDIUM}`, + LOW: `bg-success/20 ${severityToTextClassName.LOW}`, + CRITICAL: `bg-destructive ${severityToTextClassName.CRITICAL}`, + DEFAULT: `bg-foreground ${severityToTextClassName.DEFAULT}`, +} as const satisfies SeverityToClassName; diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/hooks/useTransactionMonitoringAlertsLogic/useTransactionMonitoringAlertsLogic.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/hooks/useTransactionMonitoringAlertsLogic/useTransactionMonitoringAlertsLogic.tsx index 551988a0a5..a23c749661 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/hooks/useTransactionMonitoringAlertsLogic/useTransactionMonitoringAlertsLogic.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlerts/hooks/useTransactionMonitoringAlertsLogic/useTransactionMonitoringAlertsLogic.tsx @@ -1,16 +1,16 @@ -import { useAuthenticatedUserQuery } from '@/domains/auth/hooks/queries/useAuthenticatedUserQuery/useAuthenticatedUserQuery'; -import { getAlertsSearchSchema } from '@/pages/TransactionMonitoringAlerts/get-alerts-search-schema'; +import { usePagination } from '@/common/hooks/usePagination/usePagination'; +import { useSearch } from '@/common/hooks/useSearch/useSearch'; import { useZodSearchParams } from '@/common/hooks/useZodSearchParams/useZodSearchParams'; +import { getAlertsSearchSchema } from '@/domains/alerts/helpers/get-alerts-search-schema'; +import { useAlertCorrelationIdsQuery } from '@/domains/alerts/hooks/queries/useAlertLabelsQuery/useAlertLabelsQuery'; import { useAlertsQuery } from '@/domains/alerts/hooks/queries/useAlertsQuery/useAlertsQuery'; +import { useAuthenticatedUserQuery } from '@/domains/auth/hooks/queries/useAuthenticatedUserQuery/useAuthenticatedUserQuery'; import { useUsersQuery } from '@/domains/users/hooks/queries/useUsersQuery/useUsersQuery'; import { useMemo } from 'react'; -import { usePagination } from '@/common/hooks/usePagination/usePagination'; -import { useSearch } from '@/common/hooks/useSearch/useSearch'; -import { useAlertCorrelationIdsQuery } from '@/domains/alerts/hooks/queries/useAlertLabelsQuery/useAlertLabelsQuery'; export const useTransactionMonitoringAlertsLogic = () => { const { data: session } = useAuthenticatedUserQuery(); - const AlertsSearchSchema = getAlertsSearchSchema(session?.user?.id); + const AlertsSearchSchema = getAlertsSearchSchema(); const [{ filter, sortBy, sortDir, page, pageSize, search: searchValue }] = useZodSearchParams(AlertsSearchSchema); const { data: alerts, isLoading: isLoadingAlerts } = useAlertsQuery({ diff --git a/apps/backoffice-v2/src/pages/TransactionMonitoringAlertsAnalysis/components/AlertAnalysisSheet/AlertAnalysisSheet.tsx b/apps/backoffice-v2/src/pages/TransactionMonitoringAlertsAnalysis/components/AlertAnalysisSheet/AlertAnalysisSheet.tsx index 8bc0cd1b5e..1a8c158950 100644 --- a/apps/backoffice-v2/src/pages/TransactionMonitoringAlertsAnalysis/components/AlertAnalysisSheet/AlertAnalysisSheet.tsx +++ b/apps/backoffice-v2/src/pages/TransactionMonitoringAlertsAnalysis/components/AlertAnalysisSheet/AlertAnalysisSheet.tsx @@ -1,10 +1,10 @@ import { SheetContent } from '@/common/components/atoms/Sheet'; import { Sheet } from '@/common/components/atoms/Sheet/Sheet'; -import React, { FunctionComponent, ReactNode } from 'react'; +import { DataTable } from '@/common/components/organisms/DataTable/DataTable'; import { TTransactionsList } from '@/domains/transactions/fetchers'; import { ExpandedTransactionDetails } from '@/pages/TransactionMonitoringAlertsAnalysis/components/AlertAnalysisSheet/ExpandedTransactionDetails'; import { columns } from '@/pages/TransactionMonitoringAlertsAnalysis/components/AlertAnalysisSheet/columns'; -import { DataTable } from '@/common/components/organisms/DataTable/DataTable'; +import { FunctionComponent, ReactNode } from 'react'; export interface IAlertAnalysisProps { onOpenStateChange: () => void; diff --git a/services/workflows-service/prisma/data-migrations b/services/workflows-service/prisma/data-migrations deleted file mode 160000 index 0e199894d2..0000000000 --- a/services/workflows-service/prisma/data-migrations +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0e199894d25f205daa956cd5c516a4663676aab8 diff --git a/services/workflows-service/prisma/schema.prisma b/services/workflows-service/prisma/schema.prisma index ce8aab35dc..43094933b7 100644 --- a/services/workflows-service/prisma/schema.prisma +++ b/services/workflows-service/prisma/schema.prisma @@ -714,13 +714,13 @@ enum MonitoringType { } model AlertDefinition { - id String @id @default(cuid()) - crossEnvKey String? @unique - correlationId String - monitoringType MonitoringType - name String - enabled Boolean @default(true) - description String? + id String @id @default(cuid()) + crossEnvKey String? @unique + correlationId String + monitoringType MonitoringType + name String + enabled Boolean @default(true) + description String? projectId String project Project @relation(fields: [projectId], references: [id], onUpdate: Cascade, onDelete: NoAction) @@ -839,17 +839,17 @@ model Counterparty { } model BusinessReport { - id String @id @default(cuid()) - type BusinessReportType - reportId String @unique - report Json + id String @id @default(cuid()) + type BusinessReportType + reportId String @unique + report Json riskScore Int businessId String business Business @relation(fields: [businessId], references: [id]) - projectId String - project Project @relation(fields: [projectId], references: [id]) + projectId String + project Project @relation(fields: [projectId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/services/workflows-service/scripts/alerts/generate-alerts.ts b/services/workflows-service/scripts/alerts/generate-alerts.ts index f10e466554..f37c9b8dc0 100644 --- a/services/workflows-service/scripts/alerts/generate-alerts.ts +++ b/services/workflows-service/scripts/alerts/generate-alerts.ts @@ -612,7 +612,7 @@ export const getAlertDefinitionCreateData = ( dedupeStrategy?: Partial; description?: string; }, - project: Project, + projectId: string, createdBy: string = 'SYSTEM', extraColumns: any = {}, ) => { @@ -629,7 +629,7 @@ export const getAlertDefinitionCreateData = ( inlineRule, correlationId: id, name: id, - rulesetId: `set-${id}`, + rulesetId: `set-${String(id)}`, description: description, ruleId: id, createdBy: createdBy, @@ -638,14 +638,14 @@ export const getAlertDefinitionCreateData = ( tags: [], additionalInfo: {}, ...extraColumns, - projectId: project.id, + projectId, }; }; export const generateAlertDefinitions = async ( prisma: PrismaClient | PrismaTransaction, { - project, + projectId, createdBy = 'SYSTEM', alertsDef = ALERT_DEFINITIONS, }: { @@ -653,8 +653,8 @@ export const generateAlertDefinitions = async ( | typeof ALERT_DEFINITIONS | typeof MERCHANT_MONITORING_ALERT_DEFINITIONS; createdBy?: string; - project: Project; - alertsDef?: Partial; + projectId: string; + alertsDef?: Partial; }, { crossEnvKeyPrefix = undefined, @@ -680,11 +680,11 @@ export const generateAlertDefinitions = async ( where: { correlationId_projectId: { correlationId: alertDef.correlationId, - projectId: project.id, + projectId: projectId, }, }, - create: getAlertDefinitionCreateData(alertDef, project, createdBy, extraColumns), - update: getAlertDefinitionCreateData(alertDef, project, createdBy, extraColumns), + create: getAlertDefinitionCreateData(alertDef, projectId, createdBy, extraColumns), + update: getAlertDefinitionCreateData(alertDef, projectId, createdBy, extraColumns), include: { alert: true, }, @@ -760,13 +760,13 @@ export const seedTransactionsAlerts = async ( ) => { const transactionsAlertDef = await generateAlertDefinitions(prisma, { alertsDefConfiguration: ALERT_DEFINITIONS, - project, + projectId: project.id, createdBy: faker.internet.userName(), }); const merchantMonitoringAlertDef = await generateAlertDefinitions(prisma, { alertsDefConfiguration: MERCHANT_MONITORING_ALERT_DEFINITIONS, - project, + projectId: project.id, createdBy: faker.internet.userName(), }); @@ -792,3 +792,46 @@ export const seedTransactionsAlerts = async ( ), ]); }; + +export const seedOngoingMonitoringAlerts = async ( + prisma: PrismaClient, + { + project, + businessIds, + counterpartyIds, + agentUserIds, + }: { + project: Project; + businessIds: string[]; + counterpartyIds: string[]; + agentUserIds: string[]; + }, +) => { + const merchantMonitoringAlertDef = await generateAlertDefinitions(prisma, { + alertsDefConfiguration: MERCHANT_MONITORING_ALERT_DEFINITIONS, + projectId: project.id, + createdBy: faker.internet.userName(), + }); + + await Promise.all([ + ...merchantMonitoringAlertDef.map(alertDefinition => + prisma.alert.createMany({ + data: Array.from( + { + length: faker.datatype.number({ min: 3, max: 5 }), + }, + () => ({ + alertDefinitionId: alertDefinition.id, + projectId: project.id, + ...generateFakeAlert({ + counterpartyIds, + agentUserIds, + severity: faker.helpers.arrayElement(Object.values(AlertSeverity)), + }), + }), + ), + skipDuplicates: true, + }), + ), + ]); +}; diff --git a/services/workflows-service/scripts/business-reports/seed-business-reports.ts b/services/workflows-service/scripts/business-reports/seed-business-reports.ts new file mode 100644 index 0000000000..1f5c8fe85f --- /dev/null +++ b/services/workflows-service/scripts/business-reports/seed-business-reports.ts @@ -0,0 +1,57 @@ +import { faker } from '@faker-js/faker'; +import { PrismaClient, Project } from '@prisma/client'; + +const generateFakeRiskScore = () => { + return Math.floor(Math.random() * 100) + 1; +}; + +const seedFiles = async (prisma: PrismaClient, project: Project) => { + const files = await Promise.all( + new Array(3).fill(null).map((_, index) => { + return prisma.file.create({ + data: { + fileNameOnDisk: faker.image.cats(), + uri: faker.image.cats(), + userId: '', + projectId: project.id, + }, + }); + }), + ); + + return files.map(file => file.id); +}; + +export const seedBusinessReports = async ( + prisma: PrismaClient, + { businessRiskIds, project }: { businessRiskIds: string[]; project: Project }, +) => { + const fileIds = await seedFiles(prisma, project); + + await Promise.all( + businessRiskIds + .map(businessRiskId => + fileIds.map(fileId => + prisma.businessReport.create({ + data: { + businessId: businessRiskId, + report: { + data: { + summary: { + riskScore: generateFakeRiskScore(), + }, + }, + reportFileId: fileId, + reportId: faker.datatype.uuid(), + }, + projectId: project.id, + type: 'ONGOING_MERCHANT_REPORT_T1', + riskScore: generateFakeRiskScore(), + reportId: faker.datatype.uuid(), + }, + }), + ), + ) + .flat(1), + ); +}; diff --git a/services/workflows-service/scripts/seed.ts b/services/workflows-service/scripts/seed.ts index 1ab681810b..d547882871 100644 --- a/services/workflows-service/scripts/seed.ts +++ b/services/workflows-service/scripts/seed.ts @@ -1,8 +1,21 @@ -import { hashKey } from '../src/customer/api-key/utils'; +import { CommonWorkflowStates, defaultContextSchema } from '@ballerine/common'; import { faker } from '@faker-js/faker'; import { Business, Customer, EndUser, Prisma, PrismaClient, Project } from '@prisma/client'; +import { Type } from '@sinclair/typebox'; import { hash } from 'bcrypt'; +import { hashKey } from '../src/customer/api-key/utils'; +import { env } from '../src/env'; +import type { InputJsonValue } from '../src/types'; +import { seedOngoingMonitoringAlerts, seedTransactionsAlerts } from './alerts/generate-alerts'; +import { generateTransactions } from './alerts/generate-transactions'; +import { seedBusinessReports } from './business-reports/seed-business-reports'; import { customSeed } from './custom-seed'; +import { + baseFilterAssigneeSelect, + baseFilterBusinessSelect, + baseFilterDefinitionSelect, + baseFilterEndUserSelect, +} from './filters'; import { businessIds, businessRiskIds, @@ -10,31 +23,19 @@ import { generateBusiness, generateEndUser, } from './generate-end-user'; -import { CommonWorkflowStates, defaultContextSchema } from '@ballerine/common'; import { generateUserNationalId } from './generate-user-national-id'; -import { generateDynamicDefinitionForE2eTest } from './workflows/e2e-dynamic-url-example'; -import { generateKycForE2eTest } from './workflows/kyc-dynamic-process-example'; import { generateKybDefintion } from './workflows'; -import { generateKycSessionDefinition } from './workflows/kyc-email-process-example'; -import { env } from '../src/env'; -import { generateKybKycWorkflowDefinition } from './workflows/kyb-kyc-workflow-definition'; -import { generateBaseTaskLevelStates } from './workflows/generate-base-task-level-states'; +import { generateDynamicDefinitionForE2eTest } from './workflows/e2e-dynamic-url-example'; import { generateBaseCaseLevelStatesAutoTransitionOnRevision } from './workflows/generate-base-case-level-states'; -import type { InputJsonValue } from '../src/types'; -import { generateWebsiteMonitoringExample } from './workflows/website-monitoring-workflow'; +import { generateBaseTaskLevelStates } from './workflows/generate-base-task-level-states'; import { generateCollectionKybWorkflow } from './workflows/generate-collection-kyb-workflow'; +import { generateKybKycWorkflowDefinition } from './workflows/kyb-kyc-workflow-definition'; +import { generateKycForE2eTest } from './workflows/kyc-dynamic-process-example'; +import { generateKycSessionDefinition } from './workflows/kyc-email-process-example'; +import { generateKycManualReviewRuntimeAndToken } from './workflows/runtime/geneate-kyc-manual-review-runtime-and-token'; import { generateInitialCollectionFlowExample } from './workflows/runtime/generate-initial-collection-flow-example'; import { uiKybParentWithAssociatedCompanies } from './workflows/ui-definition/kyb-with-associated-companies/ui-kyb-parent-dynamic-example'; -import { - baseFilterAssigneeSelect, - baseFilterBusinessSelect, - baseFilterDefinitionSelect, - baseFilterEndUserSelect, -} from './filters'; -import { generateTransactions } from './alerts/generate-transactions'; -import { generateKycManualReviewRuntimeAndToken } from './workflows/runtime/geneate-kyc-manual-review-runtime-and-token'; -import { Type } from '@sinclair/typebox'; -import { seedTransactionsAlerts } from './alerts/generate-alerts'; +import { generateWebsiteMonitoringExample } from './workflows/website-monitoring-workflow'; const BCRYPT_SALT: string | number = 10; @@ -986,6 +987,17 @@ async function seed() { .filter(Boolean) as string[], agentUserIds: agentUsers.map(({ id }) => id), }); + + await seedOngoingMonitoringAlerts(client, { + project: project1, + businessIds: businessRiskIds, + counterpartyIds: [...ids1, ...ids2] + .map(({ counterpartyOriginatorId }) => counterpartyOriginatorId) + .filter(Boolean) as string[], + agentUserIds: agentUsers.map(({ id }) => id), + }); + + await seedBusinessReports(client, { businessRiskIds, project: project1 }); // TODO: create business with enduser attched to them // await client.business.create({ // data: { diff --git a/services/workflows-service/src/alert/alert.controller.external.ts b/services/workflows-service/src/alert/alert.controller.external.ts index e9e09c2b70..d66a51ec4a 100644 --- a/services/workflows-service/src/alert/alert.controller.external.ts +++ b/services/workflows-service/src/alert/alert.controller.external.ts @@ -1,3 +1,4 @@ +import { AlertDefinitionService } from '@/alert-definition/alert-definition.service'; import { AlertService } from '@/alert/alert.service'; import { ProjectAssigneeGuard } from '@/alert/guards/project-assignee.guard'; import { AppLoggerService } from '@/common/app-logger/app-logger.service'; @@ -7,13 +8,17 @@ import { UseCustomerAuthGuard } from '@/common/decorators/use-customer-auth-guar import { ZodValidationPipe } from '@/common/pipes/zod.pipe'; import { PrismaService } from '@/prisma/prisma.service'; import type { AuthenticatedEntity, TProjectId } from '@/types'; +import { UserData } from '@/user/user-data.decorator'; import * as common from '@nestjs/common'; import { Res } from '@nestjs/common'; + import * as swagger from '@nestjs/swagger'; import { Alert, AlertDefinition, MonitoringType } from '@prisma/client'; +import express from 'express'; import * as errors from '../errors'; import { AlertAssigneeUniqueDto, AlertUpdateResponse } from './dtos/assign-alert.dto'; import { CreateAlertDefinitionDto } from './dtos/create-alert-definition.dto'; +import { AlertDecisionDto } from './dtos/decision-alert.dto'; import { FindAlertsDto, FindAlertsSchema } from './dtos/get-alerts.dto'; import { BulkStatus, @@ -21,10 +26,6 @@ import { TAlertTransactionResponse, TBulkAssignAlertsResponse, } from './types'; -import { AlertDecisionDto } from './dtos/decision-alert.dto'; -import { UserData } from '@/user/user-data.decorator'; -import { AlertDefinitionService } from '@/alert-definition/alert-definition.service'; -import express from 'express'; @swagger.ApiBearerAuth() @swagger.ApiTags('Alerts') diff --git a/services/workflows-service/src/business-report/business-report.repository.ts b/services/workflows-service/src/business-report/business-report.repository.ts index 4508bfd980..9ee2006b28 100644 --- a/services/workflows-service/src/business-report/business-report.repository.ts +++ b/services/workflows-service/src/business-report/business-report.repository.ts @@ -36,6 +36,14 @@ export class BusinessReportRepository { this.scopeService.scopeFindMany(args, projectIds), ); } + async findFirst( + args: Prisma.SelectSubset, + projectIds: TProjectIds, + ) { + return await this.prisma.businessReport.findFirst( + this.scopeService.scopeFindFirst(args, projectIds), + ); + } async findFirstOrThrow( args: Prisma.SelectSubset, diff --git a/services/workflows-service/src/business-report/business-report.service.ts b/services/workflows-service/src/business-report/business-report.service.ts index 6ba2c4bc81..ba8a8984a6 100644 --- a/services/workflows-service/src/business-report/business-report.service.ts +++ b/services/workflows-service/src/business-report/business-report.service.ts @@ -48,6 +48,13 @@ export class BusinessReportService { ); } + async findFirst( + args: Prisma.SelectSubset, + projectIds: TProjectIds, + ) { + return await this.businessReportRepository.findFirst(args, projectIds); + } + async findFirstOrThrow( args: Prisma.SelectSubset, projectIds: TProjectIds, diff --git a/services/workflows-service/src/business/business.controller.external.ts b/services/workflows-service/src/business/business.controller.external.ts index f2b2136d82..7c32b3bf36 100644 --- a/services/workflows-service/src/business/business.controller.external.ts +++ b/services/workflows-service/src/business/business.controller.external.ts @@ -6,23 +6,23 @@ import { plainToClass } from 'class-transformer'; import type { Request } from 'express'; import * as errors from '../errors'; // import * as nestAccessControl from 'nest-access-control'; -import { BusinessFindManyArgs } from './dtos/business-find-many-args'; -import { BusinessWhereUniqueInput } from './dtos/business-where-unique-input'; -import { BusinessModel } from './business.model'; -import { BusinessService } from './business.service'; -import { isRecordNotFoundError } from '@/prisma/prisma.util'; -import { BusinessCreateDto } from './dtos/business-create'; -import { WorkflowDefinitionModel } from '@/workflow/workflow-definition.model'; -import { WorkflowDefinitionFindManyArgs } from '@/workflow/dtos/workflow-definition-find-many-args'; -import { WorkflowService } from '@/workflow/workflow.service'; -import { makeFullWorkflow } from '@/workflow/utils/make-full-workflow'; -import { BusinessUpdateDto } from '@/business/dtos/business.update'; import { BusinessInformation } from '@/business/dtos/business-information'; -import { UseKeyAuthOrSessionGuard } from '@/common/decorators/use-key-auth-or-session-guard.decorator'; -import { UseCustomerAuthGuard } from '@/common/decorators/use-customer-auth-guard.decorator'; +import { BusinessUpdateDto } from '@/business/dtos/business.update'; +import { CurrentProject } from '@/common/decorators/current-project.decorator'; import { ProjectIds } from '@/common/decorators/project-ids.decorator'; +import { UseCustomerAuthGuard } from '@/common/decorators/use-customer-auth-guard.decorator'; +import { UseKeyAuthOrSessionGuard } from '@/common/decorators/use-key-auth-or-session-guard.decorator'; +import { isRecordNotFoundError } from '@/prisma/prisma.util'; import type { TProjectId, TProjectIds } from '@/types'; -import { CurrentProject } from '@/common/decorators/current-project.decorator'; +import { WorkflowDefinitionFindManyArgs } from '@/workflow/dtos/workflow-definition-find-many-args'; +import { makeFullWorkflow } from '@/workflow/utils/make-full-workflow'; +import { WorkflowDefinitionModel } from '@/workflow/workflow-definition.model'; +import { WorkflowService } from '@/workflow/workflow.service'; +import { BusinessModel } from './business.model'; +import { BusinessService } from './business.service'; +import { BusinessCreateDto } from './dtos/business-create'; +import { BusinessFindManyArgs } from './dtos/business-find-many-args'; +import { BusinessWhereUniqueInput } from './dtos/business-where-unique-input'; @swagger.ApiTags('Businesses') @common.Controller('external/businesses') @@ -86,7 +86,8 @@ export class BusinessControllerExternal { @swagger.ApiOkResponse({ type: BusinessModel }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) @swagger.ApiForbiddenResponse() - @UseCustomerAuthGuard() + // @UseCustomerAuthGuard() + @UseKeyAuthOrSessionGuard() async getById( @common.Param() params: BusinessWhereUniqueInput, @ProjectIds() projectIds: TProjectIds, diff --git a/services/workflows-service/src/common/app-logger/app-logger.service.ts b/services/workflows-service/src/common/app-logger/app-logger.service.ts index 244e9a5bbe..576d7294d8 100644 --- a/services/workflows-service/src/common/app-logger/app-logger.service.ts +++ b/services/workflows-service/src/common/app-logger/app-logger.service.ts @@ -1,7 +1,7 @@ import { IAppLogger, LogPayload } from '@/common/abstract-logger/abstract-logger'; +import { setLogger } from '@ballerine/workflow-core'; import { Inject, Injectable, LoggerService, OnModuleDestroy } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; -import { setLogger } from '@ballerine/workflow-core'; @Injectable() export class AppLoggerService implements LoggerService, OnModuleDestroy { diff --git a/services/workflows-service/src/data-analytics/data-analytics.service.ts b/services/workflows-service/src/data-analytics/data-analytics.service.ts index c51e940fab..c6adb87b69 100644 --- a/services/workflows-service/src/data-analytics/data-analytics.service.ts +++ b/services/workflows-service/src/data-analytics/data-analytics.service.ts @@ -65,7 +65,9 @@ export class DataAnalyticsService { inlineRule, }); - throw new Error(`No evaluation function found for rule name: ${(inlineRule as InlineRule).id}`); + throw new Error( + `No evaluation function found for rule name: ${String((inlineRule as InlineRule).id)}`, + ); } async checkMerchantOngoingAlert( diff --git a/services/workflows-service/src/data-analytics/types.ts b/services/workflows-service/src/data-analytics/types.ts index 815b11aff6..20297c5753 100644 --- a/services/workflows-service/src/data-analytics/types.ts +++ b/services/workflows-service/src/data-analytics/types.ts @@ -1,9 +1,10 @@ +import { MerchantAlertLabel } from '@/alert/consts'; import { TProjectId } from '@/types'; -import { TransactionDirection, PaymentMethod, TransactionRecordType } from '@prisma/client'; +import { PaymentMethod, TransactionDirection, TransactionRecordType } from '@prisma/client'; import { AggregateType, TIME_UNITS } from './consts'; export type InlineRule = { - id: string; + id: keyof typeof MerchantAlertLabel | string; subjects: string[] | readonly string[]; } & ( | { diff --git a/services/workflows-service/src/prisma/prisma.service.ts b/services/workflows-service/src/prisma/prisma.service.ts index 0a8bf51e3f..52848279b7 100644 --- a/services/workflows-service/src/prisma/prisma.service.ts +++ b/services/workflows-service/src/prisma/prisma.service.ts @@ -1,8 +1,9 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { AppLoggerService } from '@/common/app-logger/app-logger.service'; import { isErrorWithMessage } from '@ballerine/common'; import { Prisma, PrismaClient } from '@prisma/client'; +import { PrismaTransaction } from '@/types'; const prismaExtendedClient = (prismaClient: PrismaClient) => prismaClient.$extends({ @@ -69,9 +70,9 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul await this.$disconnect(); } - async acquireLock(lockId: number) { + async acquireLock(transaction: PrismaTransaction, lockId: number) { try { - const result = await this.$queryRaw< + const result = await transaction.$queryRaw< Array<{ acquired: boolean }> >`SELECT pg_try_advisory_lock(${lockId}) AS acquired;`; const aquiredResult = result[0]; @@ -84,10 +85,18 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul } } - async releaseLock(lockId: number): Promise { + async releaseLock(transaction: PrismaTransaction, lockId: number) { try { - await this.$queryRaw`SELECT pg_advisory_unlock(${lockId});`; - this.logger.debug('Lock released.'); + const releaseResult = await transaction.$queryRaw< + Array<{ pg_advisory_unlock: boolean }> + >`SELECT pg_advisory_unlock(${lockId});`; + const { pg_advisory_unlock: isPgLockReleased } = releaseResult[0]!; + + isPgLockReleased + ? this.logger.log('Lock released.') + : this.logger.error('Failed to release lock.', { lockId }); + + return isPgLockReleased; } catch (error) { this.logger.error(`Failed to release lock: ${isErrorWithMessage(error) && error.message}`); } diff --git a/services/workflows-service/src/transaction/transaction.service.ts b/services/workflows-service/src/transaction/transaction.service.ts index d55d423174..9949c2cb99 100644 --- a/services/workflows-service/src/transaction/transaction.service.ts +++ b/services/workflows-service/src/transaction/transaction.service.ts @@ -1,14 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { AppLoggerService } from '@/common/app-logger/app-logger.service'; +import { getErrorMessageFromPrismaError } from '@/common/filters/HttpExceptions.filter'; +import { isPrismaClientKnownRequestError } from '@/prisma/prisma.util'; +import { SentryService } from '@/sentry/sentry.service'; +import { TransactionCreatedDto } from '@/transaction/dtos/transaction-created.dto'; import { TransactionRepository } from '@/transaction/transaction.repository'; +import { TProjectId } from '@/types'; +import { Injectable } from '@nestjs/common'; +import { GetTransactionsDto } from './dtos/get-transactions.dto'; import { TransactionCreateDto } from './dtos/transaction-create.dto'; import { TransactionEntityMapper } from './transaction.mapper'; -import { AppLoggerService } from '@/common/app-logger/app-logger.service'; -import { GetTransactionsDto } from './dtos/get-transactions.dto'; -import { TProjectId } from '@/types'; -import { TransactionCreatedDto } from '@/transaction/dtos/transaction-created.dto'; -import { SentryService } from '@/sentry/sentry.service'; -import { isPrismaClientKnownRequestError } from '@/prisma/prisma.util'; -import { getErrorMessageFromPrismaError } from '@/common/filters/HttpExceptions.filter'; @Injectable() export class TransactionService { @@ -50,6 +50,7 @@ export class TransactionService { } let errorMessage = 'Unknown error'; + if (isPrismaClientKnownRequestError(error)) { errorMessage = getErrorMessageFromPrismaError(error); } else { diff --git a/services/workflows-service/src/workflow/cron/cron.module.ts b/services/workflows-service/src/workflow/cron/cron.module.ts index f6aef9bbf5..2472cce06b 100644 --- a/services/workflows-service/src/workflow/cron/cron.module.ts +++ b/services/workflows-service/src/workflow/cron/cron.module.ts @@ -4,9 +4,10 @@ import { CustomerModule } from '@/customer/customer.module'; import { OngoingMonitoringCron } from '@/workflow/cron/ongoing-monitoring.cron'; import { WorkflowModule } from '@/workflow/workflow.module'; import { Module } from '@nestjs/common'; +import { SentryModule } from '@/sentry/sentry.module'; @Module({ - imports: [WorkflowModule, BusinessModule, CustomerModule, BusinessReportModule], + imports: [WorkflowModule, BusinessModule, CustomerModule, BusinessReportModule, SentryModule], providers: [OngoingMonitoringCron], }) export class CronModule {} diff --git a/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.intg.test.ts b/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.intg.test.ts index 9a726a816f..1977419d32 100644 --- a/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.intg.test.ts +++ b/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.intg.test.ts @@ -14,6 +14,7 @@ import { import { BusinessReportService } from '@/business-report/business-report.service'; import { WorkflowDefinitionService } from '@/workflow-defintion/workflow-definition.service'; import { WorkflowService } from '@/workflow/workflow.service'; +import { SentryService } from '@/sentry/sentry.service'; describe('OngoingMonitoringCron', () => { let service: OngoingMonitoringCron; @@ -27,13 +28,14 @@ describe('OngoingMonitoringCron', () => { const module = await Test.createTestingModule({ providers: [ OngoingMonitoringCron, - { provide: PrismaService, useValue: mockPrismaService() }, + PrismaService, { provide: AppLoggerService, useValue: mockLoggerService() }, { provide: CustomerService, useValue: mockCustomerService() }, { provide: BusinessService, useValue: mockBusinessService() }, { provide: WorkflowService, useValue: mockWorkflowService }, { provide: WorkflowDefinitionService, useValue: mockWorkflowDefinitionService }, { provide: BusinessReportService, useValue: mockBusinessReportService }, + { provide: SentryService, useValue: mockSentryService() }, ], }).compile(); @@ -46,7 +48,6 @@ describe('OngoingMonitoringCron', () => { describe('handleCron', () => { it('should process businesses correctly when the lock is acquired', async () => { - jest.spyOn(prismaService, 'acquireLock').mockResolvedValue(true); jest.spyOn(customerService, 'list').mockResolvedValue(mockCustomers()); jest.spyOn(businessService, 'list').mockResolvedValue(mockBusinesses()); // Mock additional service methods as needed @@ -82,6 +83,9 @@ describe('OngoingMonitoringCron', () => { const mockPrismaService = () => ({ acquireLock: jest.fn(), releaseLock: jest.fn(), + $transaction: jest.fn(async transactionFunction => { + return mockPrismaService; + }), }); const mockWorkflowService = { @@ -98,6 +102,10 @@ describe('OngoingMonitoringCron', () => { list: jest.fn(), }); + const mockSentryService = () => ({ + captureException: jest.fn(), + }); + const mockLoggerService = () => ({ log: jest.fn(), error: jest.fn(), diff --git a/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts b/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts index 7786aa3216..aee5dd539e 100644 --- a/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts +++ b/services/workflows-service/src/workflow/cron/ongoing-monitoring.cron.ts @@ -20,6 +20,7 @@ import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Business, Project } from '@prisma/client'; import get from 'lodash/get'; +import { SentryService } from '@/sentry/sentry.service'; @Injectable() export class OngoingMonitoringCron { @@ -33,84 +34,91 @@ export class OngoingMonitoringCron { protected readonly customerService: CustomerService, protected readonly businessService: BusinessService, protected readonly businessReportService: BusinessReportService, + protected readonly sentryService: SentryService, ) {} @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async handleCron() { - const lockAcquired = await this.prisma.acquireLock(this.lockKey); + await this.prisma.$transaction(async transaction => { + const lockAcquired = await this.prisma.acquireLock(transaction, this.lockKey); - if (!lockAcquired) { - this.logger.log('Lock not acquired, another instance might be running the job.'); + if (!lockAcquired) { + this.logger.log('Lock not acquired, another instance might be running the job.'); - return; - } + return; + } - try { - const customers = await this.customerService.list({ - select: { projects: true, features: true, id: true }, - }); + try { + const customers = await this.customerService.list({ + select: { projects: true, features: true, id: true }, + }); - const processConfiguration = await this.fetchCustomerFeatureConfiguration(customers); + const processConfiguration = await this.fetchCustomerFeatureConfiguration(customers); - for (const { - projectIds, - workflowDefinition, - definitionConfig: customerProcessConfig, - } of processConfiguration) { - const businesses = await this.businessService.list({}, projectIds); + for (const { + projectIds, + workflowDefinition, + definitionConfig: customerProcessConfig, + } of processConfiguration) { + const businesses = await this.businessService.list({}, projectIds); - for (const business of businesses) { - try { - const businessProcessConfig = - business.metadata && - business.metadata.featureConfig && - this.extractDefinitionConfig(business.metadata.featureConfig); + for (const business of businesses) { + try { + const businessProcessConfig = + business.metadata && + business.metadata.featureConfig && + this.extractDefinitionConfig(business.metadata.featureConfig); - const { options: processConfig } = (businessProcessConfig || customerProcessConfig)!; - const intervalInDays = processConfig.intervalInDays; - const lastReceivedReport = await this.findLastBusinessReport(business, projectIds); + const { options: processConfig } = (businessProcessConfig || customerProcessConfig)!; + const intervalInDays = processConfig.intervalInDays; + const lastReceivedReport = await this.findLastBusinessReport(business, projectIds); - if (!lastReceivedReport) { - this.logger.debug(`No initial report found for business: ${business.id}`); + if (!lastReceivedReport) { + this.logger.debug(`No initial report found for business: ${business.id}`); - continue; - } + continue; + } - const lastReportFinishedDate = lastReceivedReport.createdAt; - const dateToRunReport = new Date( - new Date().setTime( - lastReportFinishedDate.getTime() + intervalInDays * 24 * 60 * 60 * 1000, - ), - ); + const lastReportFinishedDate = lastReceivedReport.createdAt; + const dateToRunReport = new Date( + new Date().setTime( + lastReportFinishedDate.getTime() + intervalInDays * 24 * 60 * 60 * 1000, + ), + ); - if (dateToRunReport <= new Date()) { - await this.invokeOngoingAuditReport({ - business: business as Business & { - metadata?: { featureConfig?: Record }; - }, - workflowDefinitionConfig: processConfig, - workflowDefinitionId: workflowDefinition.id, - currentProjectId: business.projectId, - projectIds: projectIds, - lastReportId: lastReceivedReport.reportId, - checkTypes: processConfig?.checkTypes, - reportType: this.processFeatureName, - }); + if (dateToRunReport <= new Date()) { + await this.invokeOngoingAuditReport({ + business: business as Business & { + metadata?: { featureConfig?: Record }; + }, + workflowDefinitionConfig: processConfig, + workflowDefinitionId: workflowDefinition.id, + currentProjectId: business.projectId, + projectIds: projectIds, + lastReportId: lastReceivedReport.reportId, + checkTypes: processConfig?.checkTypes, + reportType: this.processFeatureName, + }); + } + } catch (error) { + this.logger.error( + `Failed to Invoke Ongoing Report for businessId: ${ + business.id + } - An error occurred: ${isErrorWithMessage(error) && error.message}`, + ); } - } catch (error) { - this.logger.error( - `Failed to Invoke Ongoing Report for businessId: ${ - business.id - } - An error occurred: ${isErrorWithMessage(error) && error.message}`, - ); } } + } catch (error) { + this.logger.error(`An error occurred: ${isErrorWithMessage(error) && error.message}`); + } finally { + const lockReleased = await this.prisma.releaseLock(transaction, this.lockKey); + + if (!lockReleased) { + this.sentryService.captureException(new Error(`Failed to release lock: ${this.lockKey}`)); + } } - } catch (error) { - this.logger.error(`An error occurred: ${isErrorWithMessage(error) && error.message}`); - } finally { - await this.prisma.releaseLock(this.lockKey); - } + }); } private async findLastBusinessReport(business: Business, projectIds: TProjectIds) { diff --git a/services/workflows-service/src/workflow/hook-callback-handler.service.ts b/services/workflows-service/src/workflow/hook-callback-handler.service.ts index 35f3ed24b5..2629cb3259 100644 --- a/services/workflows-service/src/workflow/hook-callback-handler.service.ts +++ b/services/workflows-service/src/workflow/hook-callback-handler.service.ts @@ -129,7 +129,7 @@ export class HookCallbackHandlerService { const customer = await this.customerService.getByProjectId(currentProjectId); const { context } = workflowRuntime; - const { reportData: unvalidatedReportData, base64Pdf, reportId, reportType } = data; + const { reportData: onboardingReortData, base64Pdf, reportId, reportType } = data; const reportData = ReportWithRiskScoreSchema; const { documents, pdfReportBallerineFileId } = @@ -140,8 +140,7 @@ export class HookCallbackHandlerService { base64PDFString: base64Pdf as string, }); - const reportRiskScore = - ReportWithRiskScoreSchema.parse(unvalidatedReportData).summary.riskScore; + const reportRiskScore = ReportWithRiskScoreSchema.parse(onboardingReortData).summary.riskScore; const business = await this.businessService.getByCorrelationId(context.entity.id, [ currentProjectId, @@ -150,7 +149,7 @@ export class HookCallbackHandlerService { if (!business) throw new BadRequestException('Business not found.'); const currentReportId = reportId as string; - const existantBusinessReport = await this.businessReportService.findFirstOrThrow( + const existantBusinessReport = await this.businessReportService.findFirst( { where: { businessId: business.id, @@ -167,7 +166,7 @@ export class HookCallbackHandlerService { riskScore: reportRiskScore as number, report: { reportFileId: pdfReportBallerineFileId, - data: reportData as InputJsonValue, + data: onboardingReortData as InputJsonValue, }, reportId: currentReportId, businessId: business.id, @@ -226,7 +225,7 @@ export class HookCallbackHandlerService { const customer = await this.customerService.getByProjectId(currentProjectId); if (comparedToReportId) { - const comparedToReport = await this.businessReportService.findFirstOrThrow( + const comparedToReport = await this.businessReportService.findFirst( { where: { businessId,
+ The ongoing monitoring has detected new violations on the merchant, that have raised + its risk score. + Please review the violations and resolve.{' '} +