From 3b91dcd0308252eff5fff466e4813cef89c83be3 Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Fri, 7 Mar 2025 13:19:26 +0100 Subject: [PATCH 01/29] [HUMAN App] refactor: separated components form welcome page (#3160) --- .../src/modules/homepage/components/index.ts | 2 - .../homepage/components/logo-section.tsx | 72 +++++++++++ .../homepage/components/sign-in-section.tsx | 42 ++++++ .../modules/homepage/components/welcome.tsx | 121 ++---------------- 4 files changed, 122 insertions(+), 115 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/modules/homepage/components/logo-section.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/homepage/components/sign-in-section.tsx diff --git a/packages/apps/human-app/frontend/src/modules/homepage/components/index.ts b/packages/apps/human-app/frontend/src/modules/homepage/components/index.ts index c8bab39a3b..8a804abdf7 100644 --- a/packages/apps/human-app/frontend/src/modules/homepage/components/index.ts +++ b/packages/apps/human-app/frontend/src/modules/homepage/components/index.ts @@ -1,3 +1 @@ export * from './home-container'; -export * from './welcome'; -export * from './choose-sign-up-account-type'; diff --git a/packages/apps/human-app/frontend/src/modules/homepage/components/logo-section.tsx b/packages/apps/human-app/frontend/src/modules/homepage/components/logo-section.tsx new file mode 100644 index 0000000000..6116bb99a3 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/homepage/components/logo-section.tsx @@ -0,0 +1,72 @@ +import { Grid, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + MobileHomeIcons, + HomepageWorkIcon, + HomepageUserIcon, + HomepageLogoIcon, +} from '@/shared/components/ui/icons'; +import { useIsMobile } from '@/shared/hooks/use-is-mobile'; + +export function LogoSection() { + const { t } = useTranslation(); + const logoText: string = t('homepage.humanApp'); + const logoTextSplit: string[] = logoText.split(' '); + const isMobile = useIsMobile('lg'); + + return ( + + {isMobile ? ( + + + + ) : ( + + + + + + + + + + + + )} + + {logoTextSplit[0]} + + {logoTextSplit[1]} + + + + {t('homepage.completeJobs')} + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/homepage/components/sign-in-section.tsx b/packages/apps/human-app/frontend/src/modules/homepage/components/sign-in-section.tsx new file mode 100644 index 0000000000..0c33122c08 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/homepage/components/sign-in-section.tsx @@ -0,0 +1,42 @@ +import { Paper, Button, Divider } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { useHomePageState } from '@/shared/contexts/homepage-state'; +import { useIsMobile } from '@/shared/hooks/use-is-mobile'; +import { OperatorSignIn } from './operator-sign-in'; +import { WorkerSignIn } from './worker-sign-in'; + +export function SignInSection() { + const isMobile = useIsMobile('lg'); + const { colorPalette } = useColorMode(); + const { setPageView } = useHomePageState(); + const { t } = useTranslation(); + + return ( + + + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/homepage/components/welcome.tsx b/packages/apps/human-app/frontend/src/modules/homepage/components/welcome.tsx index 23ad2f1c66..2737034284 100644 --- a/packages/apps/human-app/frontend/src/modules/homepage/components/welcome.tsx +++ b/packages/apps/human-app/frontend/src/modules/homepage/components/welcome.tsx @@ -1,138 +1,33 @@ -import { Divider, Grid, Paper, Stack, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; +import { Grid } from '@mui/material'; import { useEffect } from 'react'; -import { - HomepageLogoIcon, - HomepageUserIcon, - HomepageWorkIcon, - MobileHomeIcons, -} from '@/shared/components/ui/icons'; -import { Button } from '@/shared/components/ui/button'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; -import { WorkerSignIn } from '@/modules/homepage/components/worker-sign-in'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { useHomePageState } from '@/shared/contexts/homepage-state'; import { useBackgroundContext } from '@/shared/contexts/background'; -import { OperatorSignIn } from './operator-sign-in'; +import { SignInSection } from './sign-in-section'; +import { LogoSection } from './logo-section'; export function Welcome() { - const { colorPalette, isDarkMode } = useColorMode(); + const { isDarkMode } = useColorMode(); const { setWhiteBackground } = useBackgroundContext(); - const { setPageView } = useHomePageState(); - const { t } = useTranslation(); - const logoText: string = t('homepage.humanApp'); - const logoTextSplit: string[] = logoText.split(' '); const isMobile = useIsMobile('lg'); useEffect(() => { if (!isDarkMode) { setWhiteBackground(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isDarkMode, setWhiteBackground]); return ( - - {isMobile ? ( - - - - ) : ( - - - - - - - - - - - - )} - - {logoTextSplit[0]} - - {logoTextSplit[1]} - - - - {t('homepage.completeJobs')} - - + - - - - - - + ); From f230bdc5c479b21171ab67464728162949bfe50d Mon Sep 17 00:00:00 2001 From: portuu3 <61605646+portuu3@users.noreply.github.com> Date: Fri, 7 Mar 2025 14:40:50 +0100 Subject: [PATCH 02/29] fix: correct indentation in subgraphs CI workflow configuration (#3166) * fix: correct indentation in CI workflow configuration * fix: correct indentation in subgraph CD workflow configuration * change order --- .github/workflows/cd-subgraph.yaml | 4 ++-- .github/workflows/ci-test-subgraph.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cd-subgraph.yaml b/.github/workflows/cd-subgraph.yaml index caa0051435..271dc42ad9 100644 --- a/.github/workflows/cd-subgraph.yaml +++ b/.github/workflows/cd-subgraph.yaml @@ -27,8 +27,8 @@ jobs: max-parallel: 3 steps: - uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 + name: Set up Node.js with: node-version-file: .nvmrc - name: Filter Networks diff --git a/.github/workflows/ci-test-subgraph.yaml b/.github/workflows/ci-test-subgraph.yaml index 700ed3db95..30f369d90c 100644 --- a/.github/workflows/ci-test-subgraph.yaml +++ b/.github/workflows/ci-test-subgraph.yaml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version-file: .nvmrc - run: npm install --global yarn && yarn From 01dac527f10db66e61709312bde404fc69155acd Mon Sep 17 00:00:00 2001 From: portuu3 <61605646+portuu3@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:55:28 +0100 Subject: [PATCH 03/29] remove mainnet checks from fiat payments (#3167) --- packages/apps/job-launcher/client/src/App.tsx | 23 ++--- .../components/Jobs/Create/FundingMethod.tsx | 93 +++++++++---------- .../components/TopUpAccount/TopUpMethod.tsx | 77 ++++++++------- packages/apps/job-launcher/server/ENV.md | 3 - .../src/modules/payment/payment.controller.ts | 61 ------------ 5 files changed, 92 insertions(+), 165 deletions(-) diff --git a/packages/apps/job-launcher/client/src/App.tsx b/packages/apps/job-launcher/client/src/App.tsx index 603fbe19b8..c7dbc804c7 100644 --- a/packages/apps/job-launcher/client/src/App.tsx +++ b/packages/apps/job-launcher/client/src/App.tsx @@ -1,6 +1,5 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { ProtectedRoute } from './components/ProtectedRoute'; -import { IS_MAINNET } from './constants/chains'; import './index.css'; import Layout from './layouts'; import Dashboard from './pages/Dashboard'; @@ -74,18 +73,16 @@ export default function App() { } /> - {!IS_MAINNET && ( - <> - - - - } - /> - - )} + <> + + + + } + /> + } /> diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/FundingMethod.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/FundingMethod.tsx index d63c25427f..4d7e12f2fa 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/FundingMethod.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/FundingMethod.tsx @@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom'; import { useAccount } from 'wagmi'; import fundCryptoImg from '../../../assets/fund-crypto.png'; import fundFiatImg from '../../../assets/fund-fiat.png'; -import { IS_MAINNET } from '../../../constants/chains'; import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import { useAppSelector } from '../../../state'; import { PayMethod } from '../../../types'; @@ -47,7 +46,7 @@ export const FundingMethod = () => { > {user?.whitelisted && ( - + { )} - {!IS_MAINNET && ( - + + + fiat + + Click to pay with credit card + - fiat - - Click to pay with credit card - - { + changePayMethod?.(PayMethod.Fiat); + goToNextStep?.(); }} > - - - + Pay with Credit Card + + - - )} + + {user?.whitelisted && ( - + )} - {!IS_MAINNET && ( - + + + fiat + + Click to pay with credit card + - fiat - - Click to pay with credit card - - { + onSelectMethod(PayMethod.Fiat); }} > - - + Pay with Credit Card + - - )} + + { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } return this.paymentService.createCustomerAndAssignCard(req.user); } @@ -204,12 +197,6 @@ export class PaymentController { @Request() req: RequestWithUser, @Body() data: CardConfirmDto, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } return this.paymentService.confirmCard(req.user, data); } @@ -240,12 +227,6 @@ export class PaymentController { @Body() data: PaymentFiatCreateDto, @Request() req: RequestWithUser, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } return this.paymentService.createFiatPayment(req.user, data); } @@ -276,12 +257,6 @@ export class PaymentController { @Body() data: PaymentFiatConfirmDto, @Request() req: RequestWithUser, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } return this.paymentService.confirmFiatPayment(req.user.id, data); } @@ -296,12 +271,6 @@ export class PaymentController { }) @Get('/fiat/cards') public async listPaymentMethods(@Request() req: RequestWithUser) { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } return this.paymentService.listUserPaymentMethods(req.user); } @@ -323,12 +292,6 @@ export class PaymentController { @Request() req: RequestWithUser, @Query() data: PaymentMethodIdDto, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } await this.paymentService.deletePaymentMethod( req.user, data.paymentMethodId, @@ -348,12 +311,6 @@ export class PaymentController { public async getBillingInfo( @Request() req: RequestWithUser, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } return this.paymentService.getUserBillingInfo(req.user); } @@ -371,12 +328,6 @@ export class PaymentController { @Request() req: RequestWithUser, @Body() data: BillingInfoUpdateDto, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } await this.paymentService.updateUserBillingInfo(req.user, data); } @@ -399,12 +350,6 @@ export class PaymentController { @Request() req: RequestWithUser, @Body() data: PaymentMethodIdDto, ): Promise { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } await this.paymentService.changeDefaultPaymentMethod( req.user, data.paymentMethodId, @@ -451,12 +396,6 @@ export class PaymentController { @Param('paymentId') paymentId: string, @Request() req: RequestWithUser, ) { - if (this.web3ConfigService.env === Web3Env.MAINNET) { - throw new ControlledError( - 'Temporally disabled', - HttpStatus.METHOD_NOT_ALLOWED, - ); - } return this.paymentService.getReceipt(paymentId, req.user); } } From 1c63e1dfcee26d7638ccf4e7e0a3ed620728fa53 Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:57:34 +0100 Subject: [PATCH 04/29] [HUMAN App] chore: remove playground page (#3159) --- .../modal-example/modal-example.tsx | 24 -- .../table-example/table-example.tsx | 10 - .../table-example/table-search-form.tsx | 56 ---- .../table-example/table-service.tsx | 61 ----- .../components/table-example/table.tsx | 107 -------- .../playground/views/playground.page.tsx | 16 -- .../frontend/src/router/router-paths.ts | 1 - .../human-app/frontend/src/router/routes.tsx | 5 - .../components/data-entry/form-example.tsx | 197 ------------- .../components/ui/modal/modal-content.tsx | 4 +- .../shared/components/ui/modal/modal.store.ts | 1 - .../src/shared/components/ui/ui-example.tsx | 259 ------------------ 12 files changed, 1 insertion(+), 740 deletions(-) delete mode 100644 packages/apps/human-app/frontend/src/modules/playground/components/modal-example/modal-example.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-example.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-search-form.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-service.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/playground/components/table-example/table.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/playground/views/playground.page.tsx delete mode 100644 packages/apps/human-app/frontend/src/shared/components/data-entry/form-example.tsx delete mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/ui-example.tsx diff --git a/packages/apps/human-app/frontend/src/modules/playground/components/modal-example/modal-example.tsx b/packages/apps/human-app/frontend/src/modules/playground/components/modal-example/modal-example.tsx deleted file mode 100644 index d9b083df9a..0000000000 --- a/packages/apps/human-app/frontend/src/modules/playground/components/modal-example/modal-example.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Stack } from '@mui/material'; -import Typography from '@mui/material/Typography'; - -export function ModalExample() { - return ( - -
- Example Modal - - Lorem ipsum dolor sit amet consectetur, adipisicing elit. Laborum - adipisci minima libero voluptates molestiae eligendi fugiat quas, - animi labore, perspiciatis quasi deleniti natus numquam laudantium - debitis, officia nostrum ad dolore! - -
-
- ); -} diff --git a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-example.tsx b/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-example.tsx deleted file mode 100644 index 595d3aa878..0000000000 --- a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-example.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { TableQueryContextProvider } from '@/shared/components/ui/table/table-query-context'; -import { Table } from '@/modules/playground/components/table-example/table'; - -export function TableExample() { - return ( - - - - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-search-form.tsx b/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-search-form.tsx deleted file mode 100644 index 454fc88446..0000000000 --- a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-search-form.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import Search from '@mui/icons-material/Search'; -import InputAdornment from '@mui/material/InputAdornment'; -import { FormProvider, useForm } from 'react-hook-form'; -import { Input } from '@/shared/components/data-entry/input'; -import { useColorMode } from '@/shared/contexts/color-mode'; - -interface SearchFormProps { - label: string; - name: string; - placeholder: string; - columnId: string; - fullWidth?: boolean; - updater?: (fieldValue: string) => void; -} - -export function SearchForm({ - label, - name, - placeholder, - updater, - fullWidth = false, -}: SearchFormProps) { - const { colorPalette } = useColorMode(); - const methods = useForm<{ searchValue: string }>({ - defaultValues: { - searchValue: '', - }, - }); - - return ( - - - - - ), - }} - label={label} - name={name} - onChange={(e) => { - if (updater) { - updater(e.target.value); - } - }} - placeholder={placeholder} - sx={{ - width: fullWidth ? '100%' : '15rem', - margin: fullWidth ? '0' : '1rem', - }} - /> - - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-service.tsx b/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-service.tsx deleted file mode 100644 index b5204d5ea6..0000000000 --- a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table-service.tsx +++ /dev/null @@ -1,61 +0,0 @@ -export interface Person { - name: { - firstName: string; - lastName: string; - }; - address: string; - city: string; - state: string; -} - -const data: Person[] = [ - { - name: { - firstName: 'John', - lastName: 'Doe', - }, - address: '261 Erdman Ford', - city: 'East Daphne', - state: 'Kentucky', - }, - { - name: { - firstName: 'Jane', - lastName: 'Doe', - }, - address: '769 Dominic Grove', - city: 'Columbus', - state: 'Ohio', - }, - { - name: { - firstName: 'Joe', - lastName: 'Doe', - }, - address: '566 Brakus Inlet', - city: 'South Linda', - state: 'West Virginia', - }, - { - name: { - firstName: 'Kevin', - lastName: 'Vandy', - }, - address: '722 Emie Stream', - city: 'Lincoln', - state: 'Nebraska', - }, - { - name: { - firstName: 'Joshua', - lastName: 'Rolluffs', - }, - address: '32188 Larkin Turnpike', - city: 'Omaha', - state: 'Nebraska', - }, -]; - -export function getTableData(): Promise { - return Promise.resolve(data); -} diff --git a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table.tsx b/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table.tsx deleted file mode 100644 index f086f2ac28..0000000000 --- a/packages/apps/human-app/frontend/src/modules/playground/components/table-example/table.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - MaterialReactTable, - useMaterialReactTable, - type MRT_ColumnDef, -} from 'material-react-table'; -import { useQuery } from '@tanstack/react-query'; -import { TableHeaderCell } from '@/shared/components/ui/table/table-header-cell'; -import { useTableQuery } from '@/shared/components/ui/table/table-query-hook'; -import type { Person } from '@/modules/playground/components/table-example/table-service'; -import { getTableData } from '@/modules/playground/components/table-example/table-service'; -import { SearchForm } from '@/modules/playground/components/table-example/table-search-form'; -import { Sorting } from '@/shared/components/ui/table/table-header-menu.tsx/sorting'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; - -const columns: MRT_ColumnDef[] = [ - { - accessorKey: 'name.firstName', - header: 'First Name', - size: 150, - enableSorting: true, - }, - { - accessorKey: 'name.lastName', - header: 'Last Name', - size: 150, - muiTableHeadCellProps: () => ({ - component: (props) => ( - undefined} - sortingOptions={[ - { label: 'test1', sortCallback: () => undefined }, - { label: 'test2', sortCallback: () => undefined }, - ]} - /> - } - /> - ), - }), - }, - { - accessorKey: 'address', - header: 'Address', - size: 200, - muiTableHeadCellProps: () => ({ - component: (props) => ( - undefined} - filteringOptions={[{ name: 'test', option: 'test' }]} - isChecked={(option) => option === 'test'} - setFiltering={() => undefined} - /> - } - /> - ), - }), - }, - { - accessorKey: 'city', - header: 'City', - size: 150, - }, - { - accessorKey: 'state', - header: 'State', - size: 150, - }, -]; - -export function Table() { - const { - fields: { sorting, pagination }, - } = useTableQuery(); - - const { data, isLoading, isError, isRefetching } = useQuery({ - queryKey: ['example', [sorting, pagination]], - queryFn: () => getTableData(), - }); - - const table = useMaterialReactTable({ - columns, - data: !data ? [] : data, - state: { - isLoading, - showAlertBanner: isError, - showProgressBars: isRefetching, - }, - enableColumnActions: false, - enableColumnFilters: false, - enableSorting: false, - renderTopToolbar: () => ( - - ), - }); - - return ; -} diff --git a/packages/apps/human-app/frontend/src/modules/playground/views/playground.page.tsx b/packages/apps/human-app/frontend/src/modules/playground/views/playground.page.tsx deleted file mode 100644 index b4e76710b5..0000000000 --- a/packages/apps/human-app/frontend/src/modules/playground/views/playground.page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import Paper from '@mui/material/Paper'; -import { FormExample } from '@/shared/components/data-entry/form-example'; -import { UiExample } from '@/shared/components/ui/ui-example'; - -export function Playground() { - return ( - - -

Form

- -
- ); -} diff --git a/packages/apps/human-app/frontend/src/router/router-paths.ts b/packages/apps/human-app/frontend/src/router/router-paths.ts index a5094292dd..05fdf5cdbf 100644 --- a/packages/apps/human-app/frontend/src/router/router-paths.ts +++ b/packages/apps/human-app/frontend/src/router/router-paths.ts @@ -1,6 +1,5 @@ export const routerPaths = { homePage: '/', - playground: '/playground', worker: { signIn: '/worker/sign-in', signUp: '/worker/sign-up', diff --git a/packages/apps/human-app/frontend/src/router/routes.tsx b/packages/apps/human-app/frontend/src/router/routes.tsx index d914cdbee9..42e4b02b67 100644 --- a/packages/apps/human-app/frontend/src/router/routes.tsx +++ b/packages/apps/human-app/frontend/src/router/routes.tsx @@ -14,7 +14,6 @@ import { WorkHeaderIcon, } from '@/shared/components/ui/icons'; import type { PageHeaderProps } from '@/shared/components/layout/protected/page-header'; -import { Playground } from '@/modules/playground/views/playground.page'; import { HcaptchaLabelingPage, UserStatsAccordion, @@ -43,10 +42,6 @@ export const unprotectedRoutes: RouteProps[] = [ path: routerPaths.homePage, element: , }, - { - path: routerPaths.playground, - element: , - }, { path: routerPaths.worker.signIn, element: , diff --git a/packages/apps/human-app/frontend/src/shared/components/data-entry/form-example.tsx b/packages/apps/human-app/frontend/src/shared/components/data-entry/form-example.tsx deleted file mode 100644 index 45d053fb78..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/data-entry/form-example.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useState } from 'react'; -import type { SubmitHandler } from 'react-hook-form'; -import { useForm, FormProvider } from 'react-hook-form'; -import Grid from '@mui/material/Grid'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Stack from '@mui/material/Stack'; -import { Input } from '@/shared/components/data-entry/input'; -import { Select } from '@/shared/components/data-entry/select'; -import { RadioButton } from '@/shared/components/data-entry/radio-button'; -import { Checkbox } from '@/shared/components/data-entry/checkbox'; -import { Slider } from '@/shared/components/data-entry/slider'; -import { MultiSelect } from '@/shared/components/data-entry/multi-select'; -import { Password } from '@/shared/components/data-entry/password/password'; -import { PercentsInputMask, HumanCurrencyInputMask } from './input-masks'; - -export interface Inputs { - name: string; - surname: string; - email: string; - firstCheckbox: boolean; - slider: number; - month: string; -} - -const names = [ - 'Oliver Hansen', - 'Van Henry', - 'April Tucker', - 'Ralph Hubbard', - 'Omar Alexander', - 'Carlos Abbott', - 'Miriam Wagner', - 'Bradley Wilkerson', - 'Virginia Andrews', - 'Kelly Snyder', -]; - -const accounts = [ - { - id: 1, - name: 'PL76114011245044546199764000', - value: 'PL76114011245044546199764000', - }, - { - id: 2, - name: 'PL76114011245044546199761111', - value: 'PL76114011245044546199761111', - }, - { - id: 2, - name: 'PL76114011245044546199764117', - value: 'PL76114011245044546199764117', - }, -]; - -const MIN = 3000; -const MAX = 50000; - -function CustomMarks({ min, max }: { min: number; max: number }) { - return ( - - min. {min} PLN - max. {max} PLN - - ); -} - -export function FormExample() { - const [values, setValues] = useState(); - const methods = useForm({ - defaultValues: { - name: '', - surname: '', - email: '', - }, - }); - - const onSubmit: SubmitHandler = (data) => { - const formData = { - ...data, - }; - setValues(formData); - }; - - return ( - <> - -
void methods.handleSubmit(onSubmit)(event)}> -

Inputs

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {values ? ( - - Name: {values.name} - Surname: {values.surname} - Email: {values.email} - Slider: {values.slider} - Month: {values.month} - - ) : null} - - ); -} diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-content.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-content.tsx index 26d572b36b..41d5835039 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-content.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-content.tsx @@ -1,11 +1,9 @@ import type { ComponentType } from 'react'; -import { ModalExample } from '@/modules/playground/components/modal-example/modal-example'; import { WalletConnectModal } from '@/modules/auth-web3/components/wallet-connect-modal'; import { ExpirationModal } from '@/modules/auth/components/expiration-modal'; import { ModalType } from './modal.store'; const MODAL_COMPONENTS_MAP: Record = { - [ModalType.MODAL_EXAMPLE]: ModalExample, [ModalType.WALLET_CONNECT]: WalletConnectModal, [ModalType.EXPIRATION_MODAL]: ExpirationModal, }; @@ -14,7 +12,7 @@ interface ModalContent { modalType: ModalType; } -export function ModalContent({ modalType }: ModalContent) { +export function ModalContent({ modalType }: Readonly) { const Content = MODAL_COMPONENTS_MAP[modalType]; return ; } diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts index 2424ea9eb8..fe5ae32916 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts +++ b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal.store.ts @@ -3,7 +3,6 @@ import type { ReactNode } from 'react'; import type { DialogProps as DialogMuiProps } from '@mui/material/Dialog'; export enum ModalType { - MODAL_EXAMPLE = 'MODAL_EXAMPLE', WALLET_CONNECT = 'WALLET_CONNECT', EXPIRATION_MODAL = 'EXPIRATION_MODAL', } diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/ui-example.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/ui-example.tsx deleted file mode 100644 index f3eb5d962e..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/ui/ui-example.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import Grid from '@mui/material/Grid'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import { Button } from '@/shared/components/ui/button'; -import { Loader } from '@/shared/components/ui/loader'; -import { - ModalType, - useModalStore, -} from '@/shared/components/ui/modal/modal.store'; -import { - HomepageLogoIcon, - HomepageUserIcon, - HomepageWorkIcon, - HumanLogoIcon, - ChatIcon, - HandIcon, - RefreshIcon, - UserOutlinedIcon, - WorkIcon, - HumanLogoNavbarIcon, - HelpIcon, - ProfileIcon, - CheckmarkIcon, - LockerIcon, - FiltersButtonIcon, - SortArrow, -} from '@/shared/components/ui/icons'; -import { TableExample } from '@/modules/playground/components/table-example/table-example'; -import { Alert } from '@/shared/components/ui/alert'; -import { ConnectWalletBtn } from '@/shared/components/ui/connect-wallet-btn'; -import { useColorMode } from '@/shared/contexts/color-mode'; - -export function UiExample() { - const { colorPalette } = useColorMode(); - const { openModal } = useModalStore(); - return ( -
- - Fonts - - - - IMPORTANT - To use Header 1 - 5 you have to add into Typography a prop - component - - - - - H1 / Inter Extrabold 80 - - - - H2 / Inter Semibold 60 - - - - H3 / Inter Regular 48 - - - - H4 / Inter Semibold 34 - - - - H5 / Inter Regular 24 - - - - H6 / Inter Medium 20 - - - - Subtitle 1 / Inter Regular 16 - - - - Subtitle 2 / Inter Semibold 14 - - - Body 1 / Inter Regular 16 - - Body 1 / Inter Regular 14 - - Body 3 / Inter Medium 16 - - - Button large / Inter Semibold 15 - - - - Button medium / Inter Semibold 14 - - - - Button small / Inter Semibold 13 - - - Caption / Inter Regular 12 - Overline / Inter Regular 12 - - Avatar Letter / Inter Regular 20 - - - Input Label / Inter Regular 12 - - - Helper Text / Inter Regular 12 - - - Input Text / Inter Regular 16 - - Tooltip / Inter Medium 12 - - Input Under line / Inter Semibold 12 - - - -

Buttons

- - - - - - - - -

Button sizes

- - - - - - - - - - - - - - - - -

Connect wallet button

- - -

Loader

- - - - - - -

Icons

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Modal

- - - -

Table

- - -

Alert

- - - An error has occurred, please try again. - - - Your password has been successfully updated! - - -
- ); -} From 3c7eacf2dbfb44b09378f011cbc4bfa331009b83 Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:20:13 +0100 Subject: [PATCH 05/29] [HUMAN App]: refactor: simplify operator profile page (#3158) --- .../homepage/components/operator-sign-in.tsx | 2 +- .../src/modules/homepage/hooks/index.ts | 1 + .../hooks/use-web3-signin.ts | 0 .../src/modules/operator/hooks/index.ts | 2 - .../operator/profile/components/index.ts | 2 + .../profile/components/operator-info.tsx | 127 +++++++++++++ .../profile/components/operator-stats.tsx | 75 ++++++++ .../components/profile-disable-button.tsx | 2 +- .../modules/operator/profile/hooks/index.ts | 1 + .../hooks/use-disable-operator.ts | 0 .../operator/profile/hooks/use-get-stats.ts | 14 +- .../modules/operator/profile/profile.page.tsx | 176 +----------------- 12 files changed, 222 insertions(+), 180 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/modules/homepage/hooks/index.ts rename packages/apps/human-app/frontend/src/modules/{operator => homepage}/hooks/use-web3-signin.ts (100%) create mode 100644 packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-stats.tsx rename packages/apps/human-app/frontend/src/modules/operator/{ => profile}/hooks/use-disable-operator.ts (100%) diff --git a/packages/apps/human-app/frontend/src/modules/homepage/components/operator-sign-in.tsx b/packages/apps/human-app/frontend/src/modules/homepage/components/operator-sign-in.tsx index 4486365bbd..92d38e947b 100644 --- a/packages/apps/human-app/frontend/src/modules/homepage/components/operator-sign-in.tsx +++ b/packages/apps/human-app/frontend/src/modules/homepage/components/operator-sign-in.tsx @@ -4,11 +4,11 @@ import { Link } from 'react-router-dom'; import Snackbar from '@mui/material/Snackbar'; import { Button } from '@/shared/components/ui/button'; import { useWalletConnect } from '@/shared/contexts/wallet-connect'; -import { useWeb3SignIn } from '@/modules/operator/hooks/use-web3-signin'; import { useWeb3Auth } from '@/modules/auth-web3/hooks/use-web3-auth'; import { routerPaths } from '@/router/router-paths'; import { getErrorMessageForError } from '@/shared/errors'; import { PrepareSignatureType } from '@/api/hooks/use-prepare-signature'; +import { useWeb3SignIn } from '../hooks'; export function OperatorSignIn() { const { isConnected, openModal, address } = useWalletConnect(); diff --git a/packages/apps/human-app/frontend/src/modules/homepage/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/homepage/hooks/index.ts new file mode 100644 index 0000000000..f8ca47e636 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/homepage/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-web3-signin'; diff --git a/packages/apps/human-app/frontend/src/modules/operator/hooks/use-web3-signin.ts b/packages/apps/human-app/frontend/src/modules/homepage/hooks/use-web3-signin.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/operator/hooks/use-web3-signin.ts rename to packages/apps/human-app/frontend/src/modules/homepage/hooks/use-web3-signin.ts diff --git a/packages/apps/human-app/frontend/src/modules/operator/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/operator/hooks/index.ts index 72939ae5ae..6a0e8ed3e5 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/modules/operator/hooks/index.ts @@ -1,3 +1 @@ -export * from './use-disable-operator'; export * from './use-get-keys'; -export * from './use-web3-signin'; diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/components/index.ts b/packages/apps/human-app/frontend/src/modules/operator/profile/components/index.ts index dbcc41c656..b52f63e631 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/components/index.ts +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/components/index.ts @@ -1,2 +1,4 @@ export * from './profile-disable-button'; export * from './profile-enable-button'; +export * from './operator-info'; +export * from './operator-stats'; diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx b/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx new file mode 100644 index 0000000000..ef8c77352a --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx @@ -0,0 +1,127 @@ +import { Paper, Typography, Stack, List, Grid } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useWeb3AuthenticatedUser } from '@/modules/auth-web3/hooks/use-web3-authenticated-user'; +import { CheckmarkIcon, LockerIcon } from '@/shared/components/ui/icons'; +import { ProfileListItem } from '@/shared/components/ui/profile'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { useIsMobile } from '@/shared/hooks/use-is-mobile'; +import { type GetEthKVStoreValuesSuccessResponse } from '../../hooks/use-get-keys'; +import { ProfileDisableButton } from './profile-disable-button'; +import { ProfileEnableButton } from './profile-enable-button'; + +export function OperatorInfo({ + keysData, +}: Readonly<{ keysData: GetEthKVStoreValuesSuccessResponse }>) { + const { colorPalette } = useColorMode(); + const { t } = useTranslation(); + const { user } = useWeb3AuthenticatedUser(); + const isMobile = useIsMobile('lg'); + + const isOperatorActive = user.status === 'active'; + + return ( + + + {t('operator.profile.about.header')} + + + + + + + {t('operator.profile.about.status.statusHeader')} + + {isOperatorActive ? ( + + {t('operator.profile.about.status.statusActivated')} + + + ) : ( + + {t('operator.profile.about.status.statusDeactivated')} + + + )} +
+ {isOperatorActive ? ( + + ) : ( + + )} +
+
+ + + + +
+
+
+ ); +} diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-stats.tsx b/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-stats.tsx new file mode 100644 index 0000000000..729eabc9f6 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-stats.tsx @@ -0,0 +1,75 @@ +import { Paper, Typography, Stack, List } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ProfileListItem } from '@/shared/components/ui/profile'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { useIsMobile } from '@/shared/hooks/use-is-mobile'; +import { type OperatorStatsResponse } from '../hooks'; + +export function OperatorStats({ + statsData, +}: Readonly<{ + statsData: OperatorStatsResponse; +}>) { + const isMobile = useIsMobile('lg'); + const { colorPalette } = useColorMode(); + const { t } = useTranslation(); + + return ( + + + {t('operator.profile.statistics.header')} + + + + + + + + + + + + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-disable-button.tsx b/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-disable-button.tsx index e8b89fcc4f..5cab8f9707 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-disable-button.tsx +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-disable-button.tsx @@ -1,6 +1,5 @@ import { t } from 'i18next'; import { Typography } from '@mui/material'; -import { useDisableWeb3Operator } from '@/modules/operator/hooks/use-disable-operator'; import { useConnectedWallet } from '@/shared/contexts/wallet-connect'; import { Button } from '@/shared/components/ui/button'; import type { SignatureData } from '@/api/hooks/use-prepare-signature'; @@ -8,6 +7,7 @@ import { PrepareSignatureType, usePrepareSignature, } from '@/api/hooks/use-prepare-signature'; +import { useDisableWeb3Operator } from '../hooks'; export function ProfileDisableButton() { const { address, signMessage } = useConnectedWallet(); diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts index d67ff6f5e3..033ed47173 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts @@ -1 +1,2 @@ export * from './use-get-stats'; +export * from './use-disable-operator'; diff --git a/packages/apps/human-app/frontend/src/modules/operator/hooks/use-disable-operator.ts b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-disable-operator.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/operator/hooks/use-disable-operator.ts rename to packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-disable-operator.ts diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-get-stats.ts b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-get-stats.ts index 64b186089d..8bc801b898 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-get-stats.ts +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-get-stats.ts @@ -14,10 +14,6 @@ const operatorStatsSuccessResponseSchema = z.object({ escrows_cancelled: z.number(), }); -export type OperatorStatsSuccessResponse = z.infer< - typeof operatorStatsSuccessResponseSchema ->; - const failedResponse = { workers_total: '-', assignments_completed: '-', @@ -28,6 +24,16 @@ const failedResponse = { escrows_cancelled: '-', }; +type OperatorStatsSuccessResponse = z.infer< + typeof operatorStatsSuccessResponseSchema +>; + +type OperatorStatsFailedResponse = typeof failedResponse; + +export type OperatorStatsResponse = + | OperatorStatsSuccessResponse + | OperatorStatsFailedResponse; + export function useGetOperatorStats() { const { data: keysData } = useGetKeys(); diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/profile.page.tsx b/packages/apps/human-app/frontend/src/modules/operator/profile/profile.page.tsx index a65e3d487b..6e0e610130 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/profile.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/profile.page.tsx @@ -1,25 +1,17 @@ import { useEffect } from 'react'; -import { Grid, List, Paper, Stack, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; +import { Grid } from '@mui/material'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; import { useGetKeys } from '@/modules/operator/hooks/use-get-keys'; -import { useWeb3AuthenticatedUser } from '@/modules/auth-web3/hooks/use-web3-authenticated-user'; import { PageCardError, PageCardLoader, } from '@/shared/components/ui/page-card'; import { getErrorMessageForError } from '@/shared/errors'; -import { CheckmarkIcon, LockerIcon } from '@/shared/components/ui/icons'; -import { useColorMode } from '@/shared/contexts/color-mode'; -import { ProfileListItem } from '@/shared/components/ui/profile'; import { useGetOperatorStats } from './hooks'; -import { ProfileDisableButton, ProfileEnableButton } from './components'; +import { OperatorInfo, OperatorStats } from './components'; export function OperatorProfilePage() { - const { colorPalette } = useColorMode(); - const { t } = useTranslation(); const isMobile = useIsMobile('lg'); - const { user } = useWeb3AuthenticatedUser(); const { data: keysData, error: keysError, @@ -35,8 +27,6 @@ export function OperatorProfilePage() { refetch: refetchStats, } = useGetOperatorStats(); - const isOperatorActive = user.status === 'active'; - useEffect(() => { if (keysData?.url) { void refetchStats(); @@ -58,168 +48,10 @@ export function OperatorProfilePage() { return ( - - - {t('operator.profile.about.header')} - - - - - - - {t('operator.profile.about.status.statusHeader')} - - {isOperatorActive ? ( - - {t('operator.profile.about.status.statusActivated')} - - - ) : ( - - {t('operator.profile.about.status.statusDeactivated')} - - - )} -
- {isOperatorActive ? ( - - ) : ( - - )} -
-
- - - - -
-
-
+
- - - {t('operator.profile.statistics.header')} - - - - - - - - - - - - - - - +
); From b22f74ecf96d96d80d01950fdfb65b5f713da4fd Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Mon, 10 Mar 2025 14:21:30 +0300 Subject: [PATCH 06/29] [Reputation Oracle] refactor: properly use auth strategies (#3168) --- .../server/src/app.module.ts | 22 ++++++--- .../server/src/common/decorators/index.ts | 15 +++--- .../server/src/common/enums/user.ts | 13 ----- .../src/common/filters/exception.filter.ts | 19 +++++++- .../server/src/common/guards/jwt.auth.ts | 48 ++++++++++++------- .../server/src/common/guards/roles.auth.ts | 20 ++++---- .../server/src/common/interfaces/user.ts | 9 ---- .../src/modules/auth/auth.controller.ts | 6 +-- .../server/src/modules/auth/auth.module.ts | 2 +- .../src/modules/auth/auth.service.spec.ts | 23 +++++---- .../server/src/modules/auth/auth.service.ts | 12 ++--- .../src/modules/auth/dto/sign-up.dto.ts | 8 ++-- .../src/modules/auth/strategy/jwt.http.ts | 8 ++-- .../server/src/modules/auth/token.entity.ts | 5 +- .../server/src/modules/kyc/kyc.controller.ts | 5 +- .../server/src/modules/kyc/kyc.entity.ts | 4 ++ .../server/src/modules/kyc/kyc.module.ts | 2 +- .../server/src/modules/kyc/kyc.service.ts | 2 +- .../server/src/modules/nda/nda.controller.ts | 5 +- .../server/src/modules/nda/nda.module.ts | 2 +- .../server/src/modules/nda/nda.service.ts | 3 +- .../qualification/qualification.controller.ts | 28 ++++++----- .../qualification/qualification.module.ts | 2 +- .../qualification/qualification.repository.ts | 2 +- .../qualification/qualification.service.ts | 6 +-- .../user-qualification.entity.ts | 4 ++ .../server/src/modules/user/index.ts | 4 ++ .../src/modules/user/site-key.entity.ts | 4 ++ .../src/modules/user/user.controller.ts | 10 +--- .../server/src/modules/user/user.entity.ts | 17 +++++-- .../src/modules/user/user.repository.ts | 5 +- .../src/modules/user/user.service.spec.ts | 21 ++++---- .../server/src/modules/user/user.service.ts | 9 +--- 33 files changed, 191 insertions(+), 154 deletions(-) delete mode 100644 packages/apps/reputation-oracle/server/src/common/interfaces/user.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/user/index.ts diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index 2d1b45f6d3..df1f4c41b5 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -1,34 +1,44 @@ import { Module } from '@nestjs/common'; -import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; + +import { envValidator } from './config'; +import { EnvConfigModule } from './config/config.module'; + import { DatabaseModule } from './database/database.module'; + +import { JwtAuthGuard } from './common/guards'; +import { ExceptionFilter } from './common/filters/exception.filter'; +import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { HttpValidationPipe } from './common/pipes'; + import { HealthModule } from './modules/health/health.module'; import { ReputationModule } from './modules/reputation/reputation.module'; import { Web3Module } from './modules/web3/web3.module'; -import { envValidator } from './config'; import { AuthModule } from './modules/auth/auth.module'; -import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { KycModule } from './modules/kyc/kyc.module'; import { CronJobModule } from './modules/cron-job/cron-job.module'; import { PayoutModule } from './modules/payout/payout.module'; -import { EnvConfigModule } from './config/config.module'; -import { ExceptionFilter } from './common/filters/exception.filter'; import { QualificationModule } from './modules/qualification/qualification.module'; import { EscrowCompletionModule } from './modules/escrow-completion/escrow-completion.module'; import { WebhookIncomingModule } from './modules/webhook/webhook-incoming.module'; import { WebhookOutgoingModule } from './modules/webhook/webhook-outgoing.module'; -import { UserModule } from './modules/user/user.module'; +import { UserModule } from './modules/user'; import { EmailModule } from './modules/email/module'; import { NDAModule } from './modules/nda/nda.module'; import { StorageModule } from './modules/storage/storage.module'; + import Environment from './utils/environment'; @Module({ providers: [ + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, { provide: APP_PIPE, useClass: HttpValidationPipe, diff --git a/packages/apps/reputation-oracle/server/src/common/decorators/index.ts b/packages/apps/reputation-oracle/server/src/common/decorators/index.ts index 4000eaa20b..8d3100f921 100644 --- a/packages/apps/reputation-oracle/server/src/common/decorators/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/decorators/index.ts @@ -1,8 +1,11 @@ -import { SetMetadata } from '@nestjs/common'; -import { Role } from '../enums/user'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '../../modules/user'; -export const Public = (): ((target: any, key?: any, descriptor?: any) => any) => - SetMetadata('isPublic', true); +export const Public = Reflector.createDecorator({ + key: 'isPublic', + transform: () => true, +}); -export const ROLES_KEY = 'roles'; -export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); +export const Roles = Reflector.createDecorator({ + key: 'roles', +}); diff --git a/packages/apps/reputation-oracle/server/src/common/enums/user.ts b/packages/apps/reputation-oracle/server/src/common/enums/user.ts index dec2c4bbb7..71fd66cad0 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/user.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/user.ts @@ -1,16 +1,3 @@ -export enum UserStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', - PENDING = 'pending', -} - -export enum Role { - OPERATOR = 'operator', - WORKER = 'worker', - HUMAN_APP = 'human_app', - ADMIN = 'admin', -} - export enum KycStatus { NONE = 'none', APPROVED = 'approved', diff --git a/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts b/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts index 89935a2581..26c54c859b 100644 --- a/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts +++ b/packages/apps/reputation-oracle/server/src/common/filters/exception.filter.ts @@ -8,6 +8,7 @@ import { import { Request, Response } from 'express'; import { DatabaseError } from '../errors/database'; import logger from '../../logger'; +import { transformKeysFromCamelToSnake } from '../../utils/case-converters'; @Catch() export class ExceptionFilter implements IExceptionFilter { @@ -33,8 +34,24 @@ export class ExceptionFilter implements IExceptionFilter { const exceptionResponse = exception.getResponse(); if (typeof exceptionResponse === 'string') { responseBody.message = exceptionResponse; + } else if ( + 'error' in exceptionResponse && + exceptionResponse.error === exception.message + ) { + /** + * This is the case for "sugar" exception classes + * (e.g. UnauthorizedException) that have custom message + */ + responseBody.message = exceptionResponse.error; } else { - Object.assign(responseBody, exceptionResponse); + /** + * Exception filters called after interceptors, + * so it's just a safety belt + */ + Object.assign( + responseBody, + transformKeysFromCamelToSnake(exceptionResponse), + ); } } else { this.logger.error('Unhandled exception', exception); diff --git a/packages/apps/reputation-oracle/server/src/common/guards/jwt.auth.ts b/packages/apps/reputation-oracle/server/src/common/guards/jwt.auth.ts index 8ac1c832d0..149abc48e7 100644 --- a/packages/apps/reputation-oracle/server/src/common/guards/jwt.auth.ts +++ b/packages/apps/reputation-oracle/server/src/common/guards/jwt.auth.ts @@ -1,5 +1,4 @@ import { - CanActivate, ExecutionContext, HttpException, HttpStatus, @@ -8,30 +7,45 @@ import { import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { JWT_STRATEGY_NAME } from '../constants'; +import { Public } from '../decorators'; @Injectable() -export class JwtAuthGuard - extends AuthGuard(JWT_STRATEGY_NAME) - implements CanActivate -{ +export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY_NAME) { constructor(private readonly reflector: Reflector) { super(); } - public async canActivate(context: ExecutionContext): Promise { - // `super` has to be called to set `user` on `request` - // see https://github.com/nestjs/passport/blob/master/lib/auth.guard.ts - return (super.canActivate(context) as Promise).catch((_error) => { - const isPublic = this.reflector.getAllAndOverride('isPublic', [ - context.getHandler(), - context.getClass(), - ]); + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(Public, [ + context.getHandler(), + context.getClass(), + ]); - if (isPublic) { - return true; - } + if (isPublic) { + return true; + } + return super.canActivate(context); + } + + handleRequest(error: any, user: any) { + if (error) { + /** + * Error happened while "validate" in "passport" strategy + */ + throw error; + } + + /** + * There is no error and user in different cases, e.g.: + * - jwt is not provided - strategy does not validate it + * - token is expired + * - etc. + */ + if (!user) { throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); - }); + } + + return user; } } diff --git a/packages/apps/reputation-oracle/server/src/common/guards/roles.auth.ts b/packages/apps/reputation-oracle/server/src/common/guards/roles.auth.ts index 857f50a8d2..ac4373eb82 100644 --- a/packages/apps/reputation-oracle/server/src/common/guards/roles.auth.ts +++ b/packages/apps/reputation-oracle/server/src/common/guards/roles.auth.ts @@ -6,30 +6,34 @@ import { HttpException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { Role } from '../enums/user'; -import { ROLES_KEY } from '../decorators'; +import { Roles } from '../decorators'; @Injectable() export class RolesAuthGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + const allowedRoles = this.reflector.getAllAndOverride(Roles, [ context.getHandler(), context.getClass(), ]); - if (!requiredRoles) { - return true; + /** + * We don't use this guard globally, only on specific routes, + * so it's just a safety belt + */ + if (!allowedRoles?.length) { + throw new Error( + 'Allowed roles must be specified when using RolesAuthGuard', + ); } const { user } = context.switchToHttp().getRequest(); - const isAllowed = requiredRoles.some((role) => user.role === role); - if (isAllowed) { + if (allowedRoles.includes(user.role)) { return true; } - throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); } } diff --git a/packages/apps/reputation-oracle/server/src/common/interfaces/user.ts b/packages/apps/reputation-oracle/server/src/common/interfaces/user.ts deleted file mode 100644 index edb058e711..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/interfaces/user.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { UserStatus, Role } from '../enums/user'; -import { IBase } from './base'; - -export interface IUser extends IBase { - password: string; - email: string; - status: UserStatus; - role: Role; -} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts index b61c1a6d24..351f3fed96 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts @@ -1,9 +1,9 @@ import { - ApiBearerAuth, ApiOperation, ApiResponse, ApiTags, ApiBody, + ApiBearerAuth, } from '@nestjs/swagger'; import { Body, @@ -18,7 +18,6 @@ import { } from '@nestjs/common'; import { Public } from '../../common/decorators'; import { AuthService } from './auth.service'; -import { JwtAuthGuard } from '../../common/guards'; import { RequestWithUser } from '../../common/interfaces/request'; import { HCaptchaGuard } from '../../integrations/hcaptcha/hcaptcha.guard'; import { TokenRepository } from './token.repository'; @@ -147,7 +146,7 @@ export class AuthController { description: 'Verification email resent successfully', }) @ApiBearerAuth() - @UseGuards(HCaptchaGuard, JwtAuthGuard) + @UseGuards(HCaptchaGuard) @Post('/web2/resend-verification-email') @HttpCode(200) async resendEmailVerification( @@ -223,7 +222,6 @@ export class AuthController { description: 'User logged out successfully', }) @ApiBearerAuth() - @UseGuards(JwtAuthGuard) @Post('/logout') @HttpCode(200) async logout(@Req() request: RequestWithUser): Promise { diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts index e5bf2d104c..b21c3a4f48 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts @@ -4,7 +4,7 @@ import { JwtModule } from '@nestjs/jwt'; import { AuthConfigService } from '../../config/auth-config.service'; import { HCaptchaModule } from '../../integrations/hcaptcha/hcaptcha.module'; import { EmailModule } from '../email/module'; -import { UserModule } from '../user/user.module'; +import { UserModule } from '../user'; import { Web3Module } from '../web3/web3.module'; import { JwtHttpStrategy } from './strategy'; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index bdb2097d2e..aed9ffae08 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -27,7 +27,6 @@ import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; import { ServerConfigService } from '../../config/server-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; import { JobRequestType } from '../../common/enums'; -import { Role, UserStatus } from '../../common/enums/user'; import { SignatureType } from '../../common/enums/web3'; import { generateNonce, @@ -37,9 +36,13 @@ import { import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; import { SiteKeyRepository } from '../user/site-key.repository'; import { PrepareSignatureDto } from '../user/user.dto'; -import { UserEntity } from '../user/user.entity'; -import { UserRepository } from '../user/user.repository'; -import { UserService } from '../user/user.service'; +import { + UserStatus, + UserRole, + UserEntity, + UserRepository, + UserService, +} from '../user'; import { Web3Service } from '../web3/web3.service'; import { AuthError, @@ -730,7 +733,7 @@ describe('AuthService', () => { const result = await authService.web3Signup({ address: web3PreSignUpDto.address, - type: Role.WORKER, + type: UserRole.WORKER, signature, }); @@ -754,7 +757,7 @@ describe('AuthService', () => { await expect( authService.web3Signup({ ...web3PreSignUpDto, - type: Role.WORKER, + type: UserRole.WORKER, signature: invalidSignature, }), ).rejects.toThrow( @@ -769,7 +772,7 @@ describe('AuthService', () => { await expect( authService.web3Signup({ ...web3PreSignUpDto, - type: Role.WORKER, + type: UserRole.WORKER, signature: signature, }), ).rejects.toThrow(new InvalidOperatorRoleError('')); @@ -784,7 +787,7 @@ describe('AuthService', () => { await expect( authService.web3Signup({ ...web3PreSignUpDto, - type: Role.WORKER, + type: UserRole.WORKER, signature: signature, }), ).rejects.toThrow(new InvalidOperatorFeeError('')); @@ -800,7 +803,7 @@ describe('AuthService', () => { await expect( authService.web3Signup({ ...web3PreSignUpDto, - type: Role.WORKER, + type: UserRole.WORKER, signature: signature, }), ).rejects.toThrow(new InvalidOperatorUrlError('')); @@ -817,7 +820,7 @@ describe('AuthService', () => { await expect( authService.web3Signup({ ...web3PreSignUpDto, - type: Role.WORKER, + type: UserRole.WORKER, signature: signature, }), ).rejects.toThrow(new InvalidOperatorJobTypesError('')); diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index c89dee7e5b..52a854e843 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -7,20 +7,20 @@ import { import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { OperatorStatus } from '../../common/enums/user'; import { - OperatorStatus, - Role as UserRole, UserStatus, -} from '../../common/enums/user'; -import { UserEntity } from '../user/user.entity'; -import { UserService } from '../user/user.service'; + UserRole, + UserEntity, + UserRepository, + UserService, +} from '../user'; import { TokenEntity, TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; import { verifySignature } from '../../utils/web3'; import { Web3Service } from '../web3/web3.service'; import { SignatureType } from '../../common/enums/web3'; import { prepareSignatureBody } from '../../utils/web3'; -import { UserRepository } from '../user/user.repository'; import { AuthConfigService } from '../../config/auth-config.service'; import { ServerConfigService } from '../../config/server-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts index 4ffdfc60a3..fee906fe90 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts @@ -6,7 +6,7 @@ import { IsLowercasedEnum, IsValidWeb3Signature, } from '../../../common/validators'; -import { Role } from '../../../common/enums/user'; +import { UserRole } from '../../user'; export class Web2SignUpDto { @ApiProperty() @@ -28,10 +28,10 @@ export class Web3SignUpDto { public signature: string; @ApiProperty({ - enum: Role, + enum: UserRole, }) - @IsLowercasedEnum(Role) - public type: Role; + @IsLowercasedEnum(UserRole) + public type: UserRole; @ApiProperty() @IsEthereumAddress() diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/strategy/jwt.http.ts b/packages/apps/reputation-oracle/server/src/modules/auth/strategy/jwt.http.ts index 3d494d2ea5..de3c7120ce 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/strategy/jwt.http.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/strategy/jwt.http.ts @@ -2,14 +2,12 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, Req, UnauthorizedException } from '@nestjs/common'; -import { UserEntity } from '../../user/user.entity'; import { JWT_STRATEGY_NAME, LOGOUT_PATH, RESEND_EMAIL_VERIFICATION_PATH, } from '../../../common/constants'; -import { UserStatus } from '../../../common/enums/user'; -import { UserRepository } from '../../user/user.repository'; +import { UserEntity, UserStatus, UserRepository } from '../../user'; import { AuthConfigService } from '../../../config/auth-config.service'; import { TokenRepository } from '../token.repository'; import { TokenType } from '../token.entity'; @@ -22,7 +20,7 @@ export class JwtHttpStrategy extends PassportStrategy( constructor( private readonly userRepository: UserRepository, private readonly tokenRepository: TokenRepository, - private readonly authConfigService: AuthConfigService, + authConfigService: AuthConfigService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -32,7 +30,7 @@ export class JwtHttpStrategy extends PassportStrategy( }); } - public async validate( + async validate( @Req() request: any, payload: { userId: number }, ): Promise { diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts index 04e536e5c6..df2497215e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts @@ -6,7 +6,10 @@ import { JoinColumn, ManyToOne, } from 'typeorm'; - +/** + * TODO: Leave fix follow-up refactoring + * Importing from '../user' causes circular import error here. + */ import { UserEntity } from '../user/user.entity'; import { BaseEntity } from '../../database/base.entity'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.controller.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.controller.ts index 1c9e3cc10b..ebc9d6906b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.controller.ts @@ -16,7 +16,7 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; -import { JwtAuthGuard } from '../../common/guards'; +import { Public } from '../../common/decorators'; import { RequestWithUser } from '../../common/interfaces/request'; import { KycSessionDto, KycSignedAddressDto, KycStatusDto } from './kyc.dto'; import { KycService } from './kyc.service'; @@ -39,7 +39,6 @@ export class KycController { type: KycSessionDto, }) @ApiBearerAuth() - @UseGuards(JwtAuthGuard) @Post('/start') @HttpCode(200) async startKyc(@Req() request: RequestWithUser): Promise { @@ -72,6 +71,7 @@ export class KycController { status: 200, description: 'Kyc status updated successfully', }) + @Public() @Post('/update') @UseGuards(KycWebhookAuthGuard) @HttpCode(200) @@ -89,7 +89,6 @@ export class KycController { type: KycSignedAddressDto, }) @ApiBearerAuth() - @UseGuards(JwtAuthGuard) @Get('/on-chain') @HttpCode(200) async getSignedAddress( diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts index b8875aa5b1..d07113918a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts @@ -3,6 +3,10 @@ import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; import { KycStatus } from '../../common/enums/user'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; +/** + * TODO: Leave fix follow-up refactoring + * Importing from '../user' causes circular import error here. + */ import { UserEntity } from '../user/user.entity'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'kycs' }) diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.module.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.module.ts index 8e1b7693d9..13b247b083 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.module.ts @@ -1,7 +1,7 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { UserModule } from '../user/user.module'; +import { UserModule } from '../user'; import { Web3Module } from '../web3/web3.module'; import { KycService } from './kyc.service'; diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts index be14cfcc22..3ab2e66e8d 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { UserEntity } from '../user/user.entity'; +import { UserEntity } from '../user'; import { HttpService } from '@nestjs/axios'; import { KycSessionDto, KycSignedAddressDto, KycStatusDto } from './kyc.dto'; import { KycRepository } from './kyc.repository'; diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts index 2c4da13cfd..3c63ae4520 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts @@ -5,7 +5,6 @@ import { Body, Req, UseFilters, - UseGuards, HttpCode, } from '@nestjs/common'; import { NDAService } from './nda.service'; @@ -16,16 +15,14 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { JwtAuthGuard } from 'src/common/guards'; import { RequestWithUser } from 'src/common/interfaces/request'; import { NDAErrorFilter } from './nda.error.filter'; import { AuthConfigService } from 'src/config/auth-config.service'; import { NDASignatureDto } from './nda.dto'; @ApiTags('NDA') -@UseFilters(NDAErrorFilter) @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseFilters(NDAErrorFilter) @Controller('nda') export class NDAController { constructor( diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts index 7aaa8a5435..e9a0936013 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { NDAController } from './nda.controller'; import { NDAService } from './nda.service'; -import { UserModule } from '../user/user.module'; +import { UserModule } from '../user'; @Module({ imports: [UserModule], diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts index f5e9ca87fa..e22543d5cd 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { AuthConfigService } from '../../config/auth-config.service'; -import { UserEntity } from '../user/user.entity'; -import { UserRepository } from '../user/user.repository'; +import { UserEntity, UserRepository } from '../user'; import { NDASignatureDto } from './nda.dto'; import { NDAError, NDAErrorMessage } from './nda.error'; diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts index 1b39c55f5b..9a375f641d 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.controller.ts @@ -24,14 +24,13 @@ import { QualificationDto, } from './qualification.dto'; import { QualificationErrorFilter } from './qualification.error.filter'; -import { JwtAuthGuard, RolesAuthGuard } from '../../common/guards'; +import { RolesAuthGuard } from '../../common/guards'; import { QualificationService } from './qualification.service'; -import { Roles } from '../../common/decorators'; -import { Role } from '../../common/enums/user'; +import { Public, Roles } from '../../common/decorators'; +import { UserRole } from '../user'; @ApiTags('Qualification') @Controller('qualifications') -@ApiBearerAuth() @UseFilters(QualificationErrorFilter) export class QualificationController { constructor(private readonly qualificationService: QualificationService) {} @@ -43,8 +42,9 @@ export class QualificationController { description: 'Qualification created successfully', type: QualificationDto, }) - @UseGuards(JwtAuthGuard, RolesAuthGuard) - @Roles(Role.ADMIN) + @ApiBearerAuth() + @UseGuards(RolesAuthGuard) + @Roles([UserRole.ADMIN]) @Post() @HttpCode(201) /** @@ -67,6 +67,7 @@ export class QualificationController { type: QualificationDto, isArray: true, }) + @Public() @Get() @HttpCode(200) async getQualifications(): Promise { @@ -86,8 +87,9 @@ export class QualificationController { status: 422, description: 'Cannot delete qualification', }) - @UseGuards(JwtAuthGuard, RolesAuthGuard) - @Roles(Role.ADMIN) + @ApiBearerAuth() + @UseGuards(RolesAuthGuard) + @Roles([UserRole.ADMIN]) @Delete('/:reference') @HttpCode(204) async deleteQualification( @@ -103,8 +105,9 @@ export class QualificationController { description: 'Qualification assigned successfully', }) @ApiResponse({ status: 422, description: 'No users found for operation' }) - @UseGuards(JwtAuthGuard, RolesAuthGuard) - @Roles(Role.ADMIN) + @ApiBearerAuth() + @UseGuards(RolesAuthGuard) + @Roles([UserRole.ADMIN]) @Post('/:reference/assign') @HttpCode(200) async assign( @@ -124,8 +127,9 @@ export class QualificationController { description: 'Qualification unassigned successfully', }) @ApiResponse({ status: 422, description: 'No users found for operation' }) - @UseGuards(JwtAuthGuard, RolesAuthGuard) - @Roles(Role.ADMIN) + @ApiBearerAuth() + @UseGuards(RolesAuthGuard) + @Roles([UserRole.ADMIN]) @Post('/:reference/unassign') @HttpCode(200) async unassign( diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts index 7f0176383c..75c2aa1ed1 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { UserModule } from '../user/user.module'; +import { UserModule } from '../user'; import { QualificationService } from './qualification.service'; import { QualificationRepository } from './qualification.repository'; diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts index 814904ac0d..a0a2907103 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { BaseRepository } from '../../database/base.repository'; import { DataSource, In, IsNull, MoreThan } from 'typeorm'; import { QualificationEntity } from './qualification.entity'; -import { UserEntity } from '../user/user.entity'; +import { UserEntity } from '../user'; import { UserQualificationEntity } from './user-qualification.entity'; @Injectable() diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts index d717b520da..a05ffce384 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts @@ -2,9 +2,7 @@ import { Injectable } from '@nestjs/common'; import { CreateQualificationDto, QualificationDto } from './qualification.dto'; import { QualificationEntity } from './qualification.entity'; import { QualificationRepository } from './qualification.repository'; -import { UserEntity } from '../user/user.entity'; -import { UserRepository } from '../user/user.repository'; -import { UserStatus, Role } from '../../common/enums/user'; +import { UserEntity, UserRepository, UserStatus, UserRole } from '../user'; import { UserQualificationEntity } from './user-qualification.entity'; import { ServerConfigService } from '../../config/server-config.service'; import { @@ -184,7 +182,7 @@ export class QualificationService { public async getWorkers(addresses: string[]): Promise { const users = await this.userRepository.findByAddress( addresses, - Role.WORKER, + UserRole.WORKER, UserStatus.ACTIVE, ); diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts index d7bf259a58..2248fcdd80 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts @@ -1,6 +1,10 @@ import { Entity, ManyToOne } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; +/** + * TODO: Leave fix follow-up refactoring + * Importing from '../user' causes circular import error here. + */ import { UserEntity } from '../user/user.entity'; import { QualificationEntity } from '../qualification/qualification.entity'; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/index.ts b/packages/apps/reputation-oracle/server/src/modules/user/index.ts new file mode 100644 index 0000000000..c12012b2b1 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/user/index.ts @@ -0,0 +1,4 @@ +export { UserEntity, UserStatus, Role as UserRole } from './user.entity'; +export { UserRepository } from './user.repository'; +export { UserService } from './user.service'; +export { UserModule } from './user.module'; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts index 148220fbc8..2dad37348c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts @@ -1,6 +1,10 @@ import { Entity, Column, JoinColumn, ManyToOne, Unique } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; +/** + * TODO: Leave fix follow-up refactoring + * Importing from '../user' causes circular import error here. + */ import { UserEntity } from './user.entity'; import { SiteKeyType } from '../../common/enums'; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts index e44f86e600..a3eb05498b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts @@ -17,7 +17,6 @@ import { UseFilters, } from '@nestjs/common'; import { Public } from '../../common/decorators'; -import { JwtAuthGuard } from '../../common/guards'; import { SignatureType } from '../../common/enums/web3'; import { RequestWithUser } from '../../common/interfaces/request'; import { Web3ConfigService } from '../../config/web3-config.service'; @@ -47,9 +46,9 @@ import { UserErrorFilter } from './user.error.filter'; * 2) Move "prepare-signature" out of this module */ @ApiTags('User') +@ApiBearerAuth() @Controller('/user') @UseFilters(UserErrorFilter) -@ApiBearerAuth() export class UserController { constructor( private readonly userService: UserService, @@ -66,7 +65,6 @@ export class UserController { description: 'Labeler registered successfully', type: RegisterLabelerResponseDto, }) - @UseGuards(JwtAuthGuard) @Post('/register-labeler') @HttpCode(200) async registerLabeler( @@ -90,7 +88,6 @@ export class UserController { status: 409, description: 'Provided address already registered', }) - @UseGuards(JwtAuthGuard) @Post('/register-address') @HttpCode(200) async registerAddress( @@ -109,7 +106,6 @@ export class UserController { status: 200, description: 'Operator enabled succesfully', }) - @UseGuards(JwtAuthGuard) @Post('/enable-operator') @HttpCode(200) async enableOperator( @@ -128,7 +124,6 @@ export class UserController { status: 200, description: 'Operator disabled succesfully', }) - @UseGuards(JwtAuthGuard) @Post('/disable-operator') @HttpCode(200) async disableOperator( @@ -181,7 +176,7 @@ export class UserController { description: 'Oracle registered successfully', type: RegistrationInExchangeOracleDto, }) - @UseGuards(HCaptchaGuard, JwtAuthGuard) + @UseGuards(HCaptchaGuard) @Post('/exchange-oracle-registration') @HttpCode(200) async registrationInExchangeOracle( @@ -210,7 +205,6 @@ export class UserController { status: 401, description: 'Unauthorized. Missing or invalid credentials.', }) - @UseGuards(JwtAuthGuard) @Get('/exchange-oracle-registration') async getRegistrationInExchangeOracles( @Req() request: RequestWithUser, diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts index 7ecceee3dd..07cf52a5d6 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts @@ -2,16 +2,27 @@ import { Exclude } from 'class-transformer'; import { Column, Entity, OneToMany, OneToOne } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; -import { UserStatus, Role } from '../../common/enums/user'; -import { IUser } from '../../common/interfaces/user'; import { BaseEntity } from '../../database/base.entity'; import { TokenEntity } from '../auth/token.entity'; import { KycEntity } from '../kyc/kyc.entity'; import { SiteKeyEntity } from './site-key.entity'; import { UserQualificationEntity } from '../qualification/user-qualification.entity'; +export enum UserStatus { + ACTIVE = 'active', + PENDING = 'pending', + INACTIVE = 'inactive', +} + +export enum Role { + OPERATOR = 'operator', + WORKER = 'worker', + HUMAN_APP = 'human_app', + ADMIN = 'admin', +} + @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'users' }) -export class UserEntity extends BaseEntity implements IUser { +export class UserEntity extends BaseEntity { @Exclude() @Column({ type: 'varchar', nullable: true }) public password: string; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts index c39bbf990d..6abb316963 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts @@ -1,12 +1,11 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BaseRepository } from '../../database/base.repository'; -import { UserEntity } from './user.entity'; -import { Role, UserStatus } from '../../common/enums/user'; +import { Role, UserStatus, UserEntity } from './user.entity'; @Injectable() export class UserRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { super(UserEntity, dataSource); } diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts index 83abc43150..f1d192fea9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts @@ -1,15 +1,16 @@ -import { Test } from '@nestjs/testing'; import { createMock } from '@golevelup/ts-jest'; +import { ChainId, KVStoreClient, KVStoreUtils } from '@human-protocol/sdk'; + +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { DeepPartial } from 'typeorm'; + import { UserRepository } from './user.repository'; import { UserService } from './user.service'; import { RegistrationInExchangeOracleDto } from './user.dto'; -import { UserEntity } from './user.entity'; -import { - KycStatus, - OperatorStatus, - UserStatus, - Role, -} from '../../common/enums/user'; +import { UserStatus, Role, UserEntity } from './user.entity'; +import { KycStatus, OperatorStatus } from '../../common/enums/user'; import { signMessage, prepareSignatureBody } from '../../utils/web3'; import { MOCK_ADDRESS, @@ -17,9 +18,6 @@ import { MOCK_PRIVATE_KEY, } from '../../../test/constants'; import { Web3Service } from '../web3/web3.service'; -import { DeepPartial } from 'typeorm'; -import { ChainId, KVStoreClient, KVStoreUtils } from '@human-protocol/sdk'; -import { ConfigService } from '@nestjs/config'; import { SignatureBodyDto } from '../user/user.dto'; import { SignatureType } from '../../common/enums/web3'; import { Web3ConfigService } from '../../config/web3-config.service'; @@ -27,7 +25,6 @@ import { SiteKeyRepository } from './site-key.repository'; import { SiteKeyEntity } from './site-key.entity'; import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; -import { HttpService } from '@nestjs/axios'; import { UserError, UserErrorMessage, diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts index 0b48ae1446..d940e5109e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts @@ -3,12 +3,7 @@ import { Injectable } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; -import { - KycStatus, - OperatorStatus, - UserStatus, - Role, -} from '../../common/enums/user'; +import { KycStatus, OperatorStatus } from '../../common/enums/user'; import { SignatureType } from '../../common/enums/web3'; import { SiteKeyType } from '../../common/enums'; import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; @@ -21,7 +16,7 @@ import { Web3Service } from '../web3/web3.service'; import { SiteKeyEntity } from './site-key.entity'; import { SiteKeyRepository } from './site-key.repository'; import { RegisterAddressRequestDto } from './user.dto'; -import { UserEntity } from './user.entity'; +import { Role, UserStatus, UserEntity } from './user.entity'; import { UserError, UserErrorMessage, From b79c9068dcbc3a6d2f26825ccd64e8b98f1b9673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:35:37 +0100 Subject: [PATCH 07/29] [Job Launcher] Multitoken (#3164) * feat: add multitoken support * feat: add InvalidChainId error and update getTokens response structure * feat: update payment module to streamline token handling and improve UI interactions * feat: update API endpoint for fetching available tokens * fix: correct import paths in payment service module * Remove unnecesary check for HMT address and fix lint problems --- .../client/src/components/Icons/chains.tsx | 3 + .../components/Jobs/Create/CryptoPayForm.tsx | 20 +++---- .../components/Jobs/Create/FiatPayForm.tsx | 20 ++++--- .../src/components/TokenSelect/index.tsx | 35 +++++++---- .../TopUpAccount/CryptoTopUpForm.tsx | 27 +++++---- .../client/src/services/payment.ts | 8 ++- .../common/config/network-config.service.ts | 27 +-------- .../server/src/common/constants/errors.ts | 1 + .../server/src/common/constants/payment.ts | 1 + .../server/src/common/constants/tokens.ts | 37 ++++++++++++ .../server/src/common/enums/job.ts | 1 + .../server/src/common/validators/tokens.ts | 37 ++++++++++++ .../1741276609531-removeTokenEnum.ts | 35 +++++++++++ .../server/src/modules/job/job.dto.ts | 3 +- .../server/src/modules/job/job.entity.ts | 13 +--- .../server/src/modules/job/job.service.ts | 6 +- .../src/modules/payment/payment.controller.ts | 60 ++++++++++++++----- .../server/src/modules/payment/payment.dto.ts | 3 +- .../src/modules/payment/payment.service.ts | 5 +- 19 files changed, 239 insertions(+), 103 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/common/constants/tokens.ts create mode 100644 packages/apps/job-launcher/server/src/common/validators/tokens.ts create mode 100644 packages/apps/job-launcher/server/src/database/migrations/1741276609531-removeTokenEnum.ts diff --git a/packages/apps/job-launcher/client/src/components/Icons/chains.tsx b/packages/apps/job-launcher/client/src/components/Icons/chains.tsx index 10b628ef7a..45909024a7 100644 --- a/packages/apps/job-launcher/client/src/components/Icons/chains.tsx +++ b/packages/apps/job-launcher/client/src/components/Icons/chains.tsx @@ -2,6 +2,7 @@ import { ChainId } from '@human-protocol/sdk'; import { ReactElement } from 'react'; import { BinanceSmartChainIcon } from './BinanceSmartChainIcon'; +import { DollarSignIcon } from './DollarSignIcon'; import { EthereumIcon } from './EthereumIcon'; import { HumanIcon } from './HumanIcon'; import { PolygonIcon } from './PolygonIcon'; @@ -18,4 +19,6 @@ export const CHAIN_ICONS: { [chainId in ChainId]?: ReactElement } = { export const TOKEN_ICONS: Record = { HMT: , + USDC: , + USDT: , }; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx index a08ffdbbee..83c592cf8a 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx @@ -25,7 +25,6 @@ import { usePublicClient, } from 'wagmi'; import { TokenSelect } from '../../../components/TokenSelect'; -import { NETWORK_TOKENS } from '../../../constants/chains'; import { useTokenRate } from '../../../hooks/useTokenRate'; import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import * as jobService from '../../../services/job'; @@ -55,7 +54,7 @@ export const CryptoPayForm = ({ const { data: signer } = useWalletClient(); const publicClient = usePublicClient(); const { user } = useAppSelector((state) => state.auth); - const { data: rate } = useTokenRate('hmt', 'usd'); + const { data: rate } = useTokenRate(tokenSymbol || 'hmt', 'usd'); useEffect(() => { const fetchJobLauncherData = async () => { @@ -103,6 +102,11 @@ export const CryptoPayForm = ({ return totalAmount - accountAmount; }, [payWithAccountBalance, totalAmount, accountAmount]); + const handleTokenChange = (symbol: string, address: string) => { + setTokenSymbol(symbol); + setTokenAddress(address); + }; + const handlePay = async () => { if (signer && tokenAddress && amount && jobRequest.chainId && tokenSymbol) { setIsLoading(true); @@ -225,16 +229,8 @@ export const CryptoPayForm = ({ )} { - const symbol = e.target.value as string; - setTokenSymbol(symbol); - setTokenAddress( - NETWORK_TOKENS[ - jobRequest.chainId! as keyof typeof NETWORK_TOKENS - ]?.[symbol.toLowerCase()], - ); - }} + value={tokenSymbol} + onTokenChange={handleTokenChange} /> (); + const [tokenSymbol, setTokenSymbol] = useState(); + + const handleTokenChange = (symbol: string, address: string) => { + setTokenSymbol(symbol); + }; useEffect(() => { const fetchJobLauncherData = async () => { @@ -183,7 +187,7 @@ export const FiatPayForm = ({ return; } - if (!tokenAddress) { + if (!tokenSymbol) { onError('Please select a token.'); return; } @@ -224,7 +228,7 @@ export const FiatPayForm = ({ fortuneRequest, CURRENCY.usd, fundAmount, - tokenAddress, + tokenSymbol, ); } else if (jobType === JobType.CVAT && cvatRequest) { await createCvatJob( @@ -232,7 +236,7 @@ export const FiatPayForm = ({ cvatRequest, CURRENCY.usd, fundAmount, - tokenAddress, + tokenSymbol, ); } else if (jobType === JobType.HCAPTCHA && hCaptchaRequest) { await createHCaptchaJob(chainId, hCaptchaRequest); @@ -337,10 +341,8 @@ export const FiatPayForm = ({ )} - setTokenAddress(e.target.value as string) - } + value={tokenSymbol} + onTokenChange={handleTokenChange} /> @@ -449,7 +451,7 @@ export const FiatPayForm = ({ !amount || (!payWithAccountBalance && !selectedCard) || hasError || - !tokenAddress + !tokenSymbol } > Pay now diff --git a/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx b/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx index 80bfa2bfb6..83981852d9 100644 --- a/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx +++ b/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx @@ -7,23 +7,27 @@ import { Select, SelectProps, } from '@mui/material'; -import { FC, useMemo } from 'react'; +import { FC, useEffect, useState } from 'react'; import { TOKEN_ICONS } from '../../components/Icons/chains'; -import { SUPPORTED_TOKEN_SYMBOLS } from '../../constants'; -import { NETWORK_TOKENS } from '../../constants/chains'; +import * as paymentService from '../../services/payment'; type TokenSelectProps = SelectProps & { chainId: ChainId; + onTokenChange: (symbol: string, address: string) => void; }; export const TokenSelect: FC = (props) => { - const availableTokens = useMemo(() => { - return SUPPORTED_TOKEN_SYMBOLS.filter( - (symbol) => - NETWORK_TOKENS[props.chainId as keyof typeof NETWORK_TOKENS]?.[ - symbol.toLowerCase() - ], - ); + const [availableTokens, setAvailableTokens] = useState<{ + [key: string]: string; + }>({}); + + useEffect(() => { + const fetchTokensData = async () => { + const tokens = await paymentService.getTokensAvailable(props.chainId); + setAvailableTokens(tokens); + }; + + fetchTokensData(); }, [props.chainId]); return ( @@ -44,9 +48,14 @@ export const TokenSelect: FC = (props) => { }, }} {...props} + onChange={(e) => { + const symbol = e.target.value as string; + const address = availableTokens[symbol]; + props.onTokenChange(symbol, address); + }} > - {availableTokens.map((symbol) => { - const IconComponent = TOKEN_ICONS[symbol]; + {Object.keys(availableTokens).map((symbol) => { + const IconComponent = TOKEN_ICONS[symbol.toUpperCase()]; return ( {IconComponent && ( @@ -54,7 +63,7 @@ export const TokenSelect: FC = (props) => { {IconComponent} )} - {symbol} + {symbol.toUpperCase()} ); })} diff --git a/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx b/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx index 2516dcf7fd..14ca90e123 100644 --- a/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx +++ b/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx @@ -15,7 +15,7 @@ import React, { useMemo, useState } from 'react'; import { Address } from 'viem'; import { useAccount, useWalletClient, usePublicClient } from 'wagmi'; import { TokenSelect } from '../../components/TokenSelect'; -import { NETWORK_TOKENS, SUPPORTED_CHAIN_IDS } from '../../constants/chains'; +import { SUPPORTED_CHAIN_IDS } from '../../constants/chains'; import { useTokenRate } from '../../hooks/useTokenRate'; import { useSnackbar } from '../../providers/SnackProvider'; import * as paymentService from '../../services/payment'; @@ -27,19 +27,25 @@ export const CryptoTopUpForm = () => { const { isConnected, chain } = useAccount(); const dispatch = useAppDispatch(); const [tokenAddress, setTokenAddress] = useState(); + const [tokenSymbol, setTokenSymbol] = useState(); const [amount, setAmount] = useState(); const [isSuccess, setIsSuccess] = useState(false); const [isLoading, setIsLoading] = useState(false); const publicClient = usePublicClient(); const { data: signer } = useWalletClient(); - const { data: rate } = useTokenRate('hmt', 'usd'); + const { data: rate } = useTokenRate(tokenSymbol || 'hmt', 'usd'); const { showError } = useSnackbar(); const totalAmount = useMemo(() => { - if (!amount) return 0; + if (!amount || !rate) return 0; return parseFloat(amount) * rate; }, [amount, rate]); + const handleTokenChange = (symbol: string, address: string) => { + setTokenSymbol(symbol); + setTokenAddress(address); + }; + const handleTopUpAccount = async () => { if (!signer || !chain || !tokenAddress || !amount) return; @@ -115,15 +121,8 @@ export const CryptoTopUpForm = () => { )} { - const symbol = e.target.value as string; - setTokenAddress( - NETWORK_TOKENS[chain?.id as keyof typeof NETWORK_TOKENS]?.[ - symbol.toLowerCase() - ], - ); - }} + value={tokenSymbol} + onTokenChange={handleTokenChange} /> { justifyContent="space-between" alignItems="center" > - HMT Price + + {tokenSymbol?.toUpperCase()} Price + {rate?.toFixed(2)} USD diff --git a/packages/apps/job-launcher/client/src/services/payment.ts b/packages/apps/job-launcher/client/src/services/payment.ts index a04f2ced88..eae77034c3 100644 --- a/packages/apps/job-launcher/client/src/services/payment.ts +++ b/packages/apps/job-launcher/client/src/services/payment.ts @@ -1,5 +1,5 @@ +import { ChainId } from '@human-protocol/sdk'; import { WalletClient } from 'viem'; - import { PAYMENT_SIGNATURE_KEY } from '../constants/payment'; import { BillingInfo, @@ -70,6 +70,12 @@ export const getFee = async () => { return data; }; +export const getTokensAvailable = async (chainId: ChainId) => { + const { data } = await api.get(`/payment/tokens/${chainId}`); + + return data; +}; + export const getOperatorAddress = async () => { const { data } = await api.get('/web3/operator-address'); diff --git a/packages/apps/job-launcher/server/src/common/config/network-config.service.ts b/packages/apps/job-launcher/server/src/common/config/network-config.service.ts index 33e8530efc..df563652f1 100644 --- a/packages/apps/job-launcher/server/src/common/config/network-config.service.ts +++ b/packages/apps/job-launcher/server/src/common/config/network-config.service.ts @@ -1,4 +1,4 @@ -import { ChainId, NETWORKS } from '@human-protocol/sdk'; +import { ChainId } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Web3Env } from '../enums/web3'; @@ -8,13 +8,9 @@ import { TESTNET_CHAIN_IDS, } from '../constants'; -export interface TokensList { - [key: string]: string | undefined; -} export interface NetworkDto { chainId: number; rpcUrl?: string; - tokens: TokensList; } interface NetworkMapDto { @@ -34,10 +30,6 @@ export class NetworkConfigService { * The RPC URL for the Sepolia network. */ rpcUrl: this.configService.get('RPC_URL_SEPOLIA'), - tokens: { - hmt: NETWORKS[ChainId.SEPOLIA]?.hmtAddress, - usdc: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', - }, }, }), ...(this.configService.get('RPC_URL_POLYGON') && { @@ -47,10 +39,6 @@ export class NetworkConfigService { * The RPC URL for the Polygon network. */ rpcUrl: this.configService.get('RPC_URL_POLYGON'), - tokens: { - hmt: NETWORKS[ChainId.POLYGON]?.hmtAddress, - usdt: '0x170a18b9190669cda08965562745a323c907e5ec', - }, }, }), ...(this.configService.get('RPC_URL_POLYGON_AMOY') && { @@ -60,9 +48,6 @@ export class NetworkConfigService { * The RPC URL for the Polygon Amoy network. */ rpcUrl: this.configService.get('RPC_URL_POLYGON_AMOY'), - tokens: { - hmt: NETWORKS[ChainId.POLYGON_AMOY]?.hmtAddress, - }, }, }), ...(this.configService.get('RPC_URL_BSC_MAINNET') && { @@ -72,10 +57,6 @@ export class NetworkConfigService { * The RPC URL for the BSC Mainnet network. */ rpcUrl: this.configService.get('RPC_URL_BSC_MAINNET'), - tokens: { - hmt: NETWORKS[ChainId.BSC_MAINNET]?.hmtAddress, - usdt: '0x55d398326f99059fF775485246999027B3197955', - }, }, }), ...(this.configService.get('RPC_URL_BSC_TESTNET') && { @@ -85,9 +66,6 @@ export class NetworkConfigService { * The RPC URL for the BSC Testnet network. */ rpcUrl: this.configService.get('RPC_URL_BSC_TESTNET'), - tokens: { - hmt: NETWORKS[ChainId.BSC_TESTNET]?.hmtAddress, - }, }, }), ...(this.configService.get('RPC_URL_LOCALHOST') && { @@ -97,9 +75,6 @@ export class NetworkConfigService { * The RPC URL for the Localhost network. */ rpcUrl: this.configService.get('RPC_URL_LOCALHOST'), - tokens: { - hmt: NETWORKS[ChainId.LOCALHOST]?.hmtAddress, - }, }, }), }; diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index d8aaaf4af9..259c5fae44 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -107,6 +107,7 @@ export enum ErrorPayment { UnsupportedToken = 'Unsupported token', InvalidRecipient = 'Invalid recipient', ChainIdMissing = 'ChainId is missing', + InvalidChainId = 'Invalid chain id', } /** diff --git a/packages/apps/job-launcher/server/src/common/constants/payment.ts b/packages/apps/job-launcher/server/src/common/constants/payment.ts index 091ad9efab..e93e882685 100644 --- a/packages/apps/job-launcher/server/src/common/constants/payment.ts +++ b/packages/apps/job-launcher/server/src/common/constants/payment.ts @@ -3,4 +3,5 @@ import { ITokenId } from '../interfaces'; export const CoingeckoTokenId: ITokenId = { hmt: 'human-protocol', usdt: 'tether', + usdc: 'usd-coin', }; diff --git a/packages/apps/job-launcher/server/src/common/constants/tokens.ts b/packages/apps/job-launcher/server/src/common/constants/tokens.ts new file mode 100644 index 0000000000..8c0c51a7f7 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/constants/tokens.ts @@ -0,0 +1,37 @@ +import { ChainId, NETWORKS } from '@human-protocol/sdk'; +import { EscrowFundToken } from '../enums/job'; + +export const TOKEN_ADDRESSES: { + [chainId in ChainId]?: { + [token in EscrowFundToken]?: string; + }; +} = { + [ChainId.MAINNET]: { + [EscrowFundToken.HMT]: NETWORKS[ChainId.MAINNET]?.hmtAddress, + [EscrowFundToken.USDT]: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + [EscrowFundToken.USDC]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606EB48', + }, + [ChainId.SEPOLIA]: { + [EscrowFundToken.HMT]: NETWORKS[ChainId.SEPOLIA]?.hmtAddress, + [EscrowFundToken.USDT]: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + [EscrowFundToken.USDC]: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + }, + [ChainId.BSC_MAINNET]: { + [EscrowFundToken.HMT]: NETWORKS[ChainId.BSC_MAINNET]?.hmtAddress, + [EscrowFundToken.USDT]: '0x55d398326f99059fF775485246999027B3197955', + [EscrowFundToken.USDC]: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + }, + [ChainId.POLYGON]: { + [EscrowFundToken.HMT]: NETWORKS[ChainId.POLYGON]?.hmtAddress, + [EscrowFundToken.USDT]: '0x3813e82e6f7098b9583FC0F33a962D02018B6803', + [EscrowFundToken.USDC]: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + }, + [ChainId.POLYGON_AMOY]: { + [EscrowFundToken.HMT]: NETWORKS[ChainId.POLYGON_AMOY]?.hmtAddress, + [EscrowFundToken.USDC]: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', + }, + [ChainId.LOCALHOST]: { + [EscrowFundToken.HMT]: NETWORKS[ChainId.LOCALHOST]?.hmtAddress, + [EscrowFundToken.USDC]: '0x09635F643e140090A9A8Dcd712eD6285858ceBef', + }, +}; diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index 19672e6217..263d1f7b3e 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -429,4 +429,5 @@ export enum WorkerBrowser { export enum EscrowFundToken { HMT = 'hmt', USDT = 'usdt', + USDC = 'usdc', } diff --git a/packages/apps/job-launcher/server/src/common/validators/tokens.ts b/packages/apps/job-launcher/server/src/common/validators/tokens.ts new file mode 100644 index 0000000000..81e38a3fac --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/validators/tokens.ts @@ -0,0 +1,37 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { JobDto } from 'src/modules/job/job.dto'; +import { TOKEN_ADDRESSES } from '../constants/tokens'; +import { ChainId } from '@human-protocol/sdk'; + +@ValidatorConstraint({ async: false }) +export class IsValidTokenConstraint implements ValidatorConstraintInterface { + validate(token: string, args: ValidationArguments) { + const chainId = (args.object as JobDto).chainId as ChainId; + const validTokens = Object.keys(TOKEN_ADDRESSES[chainId] || {}); + return validTokens.includes(token); + } + + defaultMessage(args: ValidationArguments) { + const chainId = (args.object as JobDto).chainId as ChainId; + const validTokens = Object.keys(TOKEN_ADDRESSES[chainId] || {}); + return `Invalid token. Token must be one of the available tokens for the specified chainId: ${validTokens.join(', ')}.`; + } +} + +export function IsValidToken(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsValidTokenConstraint, + }); + }; +} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1741276609531-removeTokenEnum.ts b/packages/apps/job-launcher/server/src/database/migrations/1741276609531-removeTokenEnum.ts new file mode 100644 index 0000000000..725f17e8d1 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1741276609531-removeTokenEnum.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveTokenEnum1741276609531 implements MigrationInterface { + name = 'RemoveTokenEnum1741276609531'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" DROP COLUMN "token" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_token_enum" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ADD "token" character varying NOT NULL DEFAULT 'hmt' + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "token" DROP DEFAULT + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" DROP COLUMN "token" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_token_enum" AS ENUM('hmt', 'usdt') + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ADD "token" "hmt"."jobs_token_enum" NOT NULL + `); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts index b99c6ac62e..16658a23c8 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts @@ -38,6 +38,7 @@ import { AWSRegions, StorageProviders } from '../../common/enums/storage'; import { PageOptionsDto } from '../../common/pagination/pagination.dto'; import { IsEnumCaseInsensitive } from '../../common/decorators'; import { PaymentCurrency } from '../../common/enums/payment'; +import { IsValidToken } from '../../common/validators/tokens'; export class JobDto { @ApiProperty({ enum: ChainId, required: false, name: 'chain_id' }) @@ -84,7 +85,7 @@ export class JobDto { public paymentAmount: number; @ApiProperty({ enum: EscrowFundToken, name: 'escrow_fund_token' }) - @IsEnumCaseInsensitive(EscrowFundToken) + @IsValidToken() public escrowFundToken: EscrowFundToken; } diff --git a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts index fb2334ea75..fc97fc0806 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts @@ -2,11 +2,7 @@ import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; import { NS } from '../../common/constants'; import { IJob } from '../../common/interfaces'; -import { - EscrowFundToken, - JobRequestType, - JobStatus, -} from '../../common/enums/job'; +import { JobRequestType, JobStatus } from '../../common/enums/job'; import { BaseEntity } from '../../database/base.entity'; import { UserEntity } from '../user/user.entity'; import { PaymentEntity } from '../payment/payment.entity'; @@ -35,11 +31,8 @@ export class JobEntity extends BaseEntity implements IJob { @Column({ type: 'decimal', precision: 30, scale: 18 }) public fundAmount: number; - @Column({ - type: 'enum', - enum: EscrowFundToken, - }) - public token: EscrowFundToken; + @Column({ type: 'varchar' }) + public token: string; @Column({ type: 'varchar' }) public manifestUrl: string; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index fb813d1b8a..4d1ef5eec4 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -37,6 +37,7 @@ import { JobCaptchaMode, JobCaptchaRequestType, JobCaptchaShapeType, + EscrowFundToken, } from '../../common/enums/job'; import { FiatCurrency, @@ -122,6 +123,7 @@ import { QualificationService } from '../qualification/qualification.service'; import { WhitelistService } from '../whitelist/whitelist.service'; import { UserEntity } from '../user/user.entity'; import { RoutingProtocolService } from '../routing-protocol/routing-protocol.service'; +import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; @Injectable() export class JobService { @@ -1017,7 +1019,9 @@ export class JobService { const escrowClient = await EscrowClient.build(signer); const escrowAddress = await escrowClient.createEscrow( - NETWORKS[jobEntity.chainId as ChainId]!.hmtAddress, + (TOKEN_ADDRESSES[jobEntity.chainId as ChainId] ?? {})[ + jobEntity.token as EscrowFundToken + ]!, getTrustedHandlers(), jobEntity.userId.toString(), { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts index 1509f0213b..f85752be79 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts @@ -1,27 +1,36 @@ import { Body, Controller, + Delete, Get, + Headers, + HttpStatus, + Param, + Patch, Post, Query, Request, UseGuards, - Headers, - HttpStatus, - Patch, - Delete, - Param, } from '@nestjs/common'; import { ApiBearerAuth, - ApiTags, - ApiOperation, ApiBody, + ApiOperation, ApiResponse, + ApiTags, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../common/guards'; import { RequestWithUser } from '../../common/types'; +import { ChainId } from '@human-protocol/sdk'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { HEADER_SIGNATURE_KEY } from '../../common/constants'; +import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; +import { ControlledError } from '../../common/errors/controlled'; +import { WhitelistAuthGuard } from '../../common/guards/whitelist.auth'; +import { PageDto } from '../../common/pagination/pagination.dto'; +import { RateService } from '../rate/rate.service'; import { BillingInfoDto, BillingInfoUpdateDto, @@ -35,13 +44,7 @@ import { PaymentMethodIdDto, } from './payment.dto'; import { PaymentService } from './payment.service'; -import { HEADER_SIGNATURE_KEY } from '../../common/constants'; -import { ControlledError } from '../../common/errors/controlled'; -import { ServerConfigService } from '../../common/config/server-config.service'; -import { RateService } from '../rate/rate.service'; -import { PageDto } from '../../common/pagination/pagination.dto'; -import { WhitelistAuthGuard } from '../../common/guards/whitelist.auth'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { ErrorPayment } from 'src/common/constants/errors'; @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -398,4 +401,33 @@ export class PaymentController { ) { return this.paymentService.getReceipt(paymentId, req.user); } + + @ApiOperation({ + summary: 'Get available tokens for a network', + description: + 'Endpoint to get available tokens for a given network by chainId.', + }) + @ApiResponse({ + status: 200, + description: 'Tokens retrieved successfully', + type: [Object], + }) + @ApiResponse({ + status: 400, + description: 'Bad Request. Invalid chainId.', + }) + @Get('/tokens/:chainId') + public async getTokens( + @Param('chainId') chainId: ChainId, + ): Promise<{ [key: string]: string }> { + const tokens = TOKEN_ADDRESSES[chainId]; + if (!tokens) { + throw new ControlledError( + ErrorPayment.InvalidChainId, + HttpStatus.BAD_REQUEST, + ); + } + + return tokens; + } } diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index 688ecfe69b..935a4dce38 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -10,7 +10,6 @@ import { } from 'class-validator'; import { FiatCurrency, - PaymentCurrency, PaymentSortField, PaymentSource, PaymentStatus, @@ -82,7 +81,7 @@ export class GetRateDto { export class PaymentRefund { public refundAmount: number; - public refundCurrency: PaymentCurrency; + public refundCurrency: string; public userId: number; public jobId: number; } diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index df540f41a3..4241c0418f 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -42,6 +42,8 @@ import { UserEntity } from '../user/user.entity'; import { UserRepository } from '../user/user.repository'; import { JobRepository } from '../job/job.repository'; import { PageDto } from '../../common/pagination/pagination.dto'; +import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; +import { EscrowFundToken } from '../../common/enums/job'; @Injectable() export class PaymentService { @@ -353,7 +355,8 @@ export class PaymentService { const amount = Number(ethers.formatEther(transaction.logs[0].data)); if ( - network?.tokens[tokenId] != tokenAddress || + TOKEN_ADDRESSES[dto.chainId]?.[tokenId as EscrowFundToken] !== + tokenAddress || !CoingeckoTokenId[tokenId] ) { throw new ControlledError( From 5f7b2d9391d9b01ea74a40b9bcf36e37f3d9dbad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 16:40:15 +0100 Subject: [PATCH 08/29] chore(deps): bump @tanstack/react-query-persist-client (#3170) Bumps [@tanstack/react-query-persist-client](https://github.com/TanStack/query/tree/HEAD/packages/react-query-persist-client) from 5.66.9 to 5.67.2. - [Release notes](https://github.com/TanStack/query/releases) - [Commits](https://github.com/TanStack/query/commits/v5.67.2/packages/react-query-persist-client) --- updated-dependencies: - dependency-name: "@tanstack/react-query-persist-client" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../exchange-oracle/client/package.json | 2 +- .../apps/job-launcher/client/package.json | 2 +- packages/apps/staking/package.json | 2 +- yarn.lock | 56 ++++++++++++------- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/apps/fortune/exchange-oracle/client/package.json b/packages/apps/fortune/exchange-oracle/client/package.json index c85013f5ee..2225b09233 100644 --- a/packages/apps/fortune/exchange-oracle/client/package.json +++ b/packages/apps/fortune/exchange-oracle/client/package.json @@ -29,7 +29,7 @@ "@mui/material": "^5.16.7", "@tanstack/query-sync-storage-persister": "^5.59.0", "@tanstack/react-query": "^5.60.5", - "@tanstack/react-query-persist-client": "^5.66.9", + "@tanstack/react-query-persist-client": "^5.67.2", "axios": "^1.7.2", "ethers": "^6.13.5", "react": "^18.3.1", diff --git a/packages/apps/job-launcher/client/package.json b/packages/apps/job-launcher/client/package.json index 07249cbce7..ae17f214cf 100644 --- a/packages/apps/job-launcher/client/package.json +++ b/packages/apps/job-launcher/client/package.json @@ -16,7 +16,7 @@ "@stripe/stripe-js": "^4.2.0", "@tanstack/query-sync-storage-persister": "^5.59.0", "@tanstack/react-query": "^5.60.5", - "@tanstack/react-query-persist-client": "^5.66.9", + "@tanstack/react-query-persist-client": "^5.67.2", "axios": "^1.1.3", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.12", diff --git a/packages/apps/staking/package.json b/packages/apps/staking/package.json index 460c1b4fc4..349585ea4e 100644 --- a/packages/apps/staking/package.json +++ b/packages/apps/staking/package.json @@ -31,7 +31,7 @@ "@mui/material": "^5.16.7", "@tanstack/query-sync-storage-persister": "^5.59.0", "@tanstack/react-query": "^5.60.5", - "@tanstack/react-query-persist-client": "^5.66.9", + "@tanstack/react-query-persist-client": "^5.67.2", "axios": "^1.7.2", "ethers": "^6.13.5", "react": "^18.3.1", diff --git a/yarn.lock b/yarn.lock index 7679ce947e..8ee0e01409 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6039,10 +6039,10 @@ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.66.0.tgz#163f670b3b4e3b3cdbff6698ad44b2edfcaed185" integrity sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw== -"@tanstack/query-core@5.66.4": - version "5.66.4" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.66.4.tgz#44b87bff289466adbfa0de8daa5756cbd2d61c61" - integrity sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA== +"@tanstack/query-core@5.67.2": + version "5.67.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.67.2.tgz#3d59a7dd3613321465925975510c0bb8e603a064" + integrity sha512-+iaFJ/pt8TaApCk6LuZ0WHS/ECVfTzrxDOEL9HH9Dayyb5OVuomLzDXeSaI2GlGT/8HN7bDGiRXDts3LV+u6ww== "@tanstack/query-devtools@5.65.0": version "5.65.0" @@ -6056,12 +6056,12 @@ dependencies: "@tanstack/query-core" "5.66.0" -"@tanstack/query-persist-client-core@5.66.4": - version "5.66.4" - resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.66.4.tgz#5c0802d5e88ee5e372d7cbb82e51f8f92261d8c6" - integrity sha512-OAw98P8DhrZyxZ60Tz4Eq8KyEAKPWIf3tuSStzS0F3jovW42/RpFy7wmgg3SrOgI7/kVKspBvPQDQn6ymcpwCg== +"@tanstack/query-persist-client-core@5.67.2": + version "5.67.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.67.2.tgz#7a3f5859784b09f88e83ea801a46ac97b52d56ca" + integrity sha512-Kaon0gIERkuhFV27lEcrlLataqRHT66rOX0d559tCZKDVGHnQ7n4CsRCvmiOLAwqWf7aS0o0owmPNjNJWq+ZLQ== dependencies: - "@tanstack/query-core" "5.66.4" + "@tanstack/query-core" "5.67.2" "@tanstack/query-sync-storage-persister@^5.59.0": version "5.66.0" @@ -6078,12 +6078,12 @@ dependencies: "@tanstack/query-devtools" "5.65.0" -"@tanstack/react-query-persist-client@^5.66.9": - version "5.66.9" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.66.9.tgz#0aa8f92c185cb96fd5517f4eaf4cb52f6df2ae01" - integrity sha512-ipuTUUKqbO2F6QHKlxvKO62ar1I13JrxyPYb4Je6jEPlrm7vh8hhfmXmqIdsyle9aRQsJCe6VGPj9igATDhMlA== +"@tanstack/react-query-persist-client@^5.67.2": + version "5.67.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.67.2.tgz#d79d622dda4f8998e7d3bd6a36c2c03122343639" + integrity sha512-aRs7XENnXMzDQPsq/GKnFpEqcwj1/mtN85wzKczLHoVyvLiOt4uMYrVibQGV/z+XfBl1uR1442uACkiecYPCzw== dependencies: - "@tanstack/query-persist-client-core" "5.66.4" + "@tanstack/query-persist-client-core" "5.67.2" "@tanstack/react-query@^5.48.0", "@tanstack/react-query@^5.60.5", "@tanstack/react-query@^5.61.0": version "5.66.0" @@ -16077,6 +16077,19 @@ ox@0.6.7: abitype "^1.0.6" eventemitter3 "5.0.1" +ox@0.6.9: + version "0.6.9" + resolved "https://registry.yarnpkg.com/ox/-/ox-0.6.9.tgz#da1ee04fa10de30c8d04c15bfb80fe58b1f554bd" + integrity sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug== + dependencies: + "@adraffy/ens-normalize" "^1.10.1" + "@noble/curves" "^1.6.0" + "@noble/hashes" "^1.5.0" + "@scure/bip32" "^1.5.0" + "@scure/bip39" "^1.4.0" + abitype "^1.0.6" + eventemitter3 "5.0.1" + p-defer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-3.0.0.tgz#d1dceb4ee9b2b604b1d94ffec83760175d4e6f83" @@ -19814,9 +19827,9 @@ victory-vendor@^36.6.8: d3-timer "^3.0.1" viem@2.7.14, viem@^2.15.1: - version "2.23.5" - resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.5.tgz#50fb9ea0701d58e6a7a1714ecaa5edfa100bb391" - integrity sha512-cUfBHdFQHmBlPW0loFXda0uZcoU+uJw3NRYQRwYgkrpH6PgovH8iuVqDn6t1jZk82zny4wQL54c9dCX2W9kLMg== + version "2.23.9" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.9.tgz#b28a77aa0f0c7ab1db0cb82eed95e6dbf7a19397" + integrity sha512-y8VLPfKukrstZKTerS9bm45ajZ22wUyStF+VquK3I2OovWLOyXSbQmJWei8syMFhp1uwhxh1tb0fAdx0WSRZWg== dependencies: "@noble/curves" "1.8.1" "@noble/hashes" "1.7.1" @@ -19824,8 +19837,8 @@ viem@2.7.14, viem@^2.15.1: "@scure/bip39" "1.5.4" abitype "1.0.8" isows "1.0.6" - ox "0.6.7" - ws "8.18.0" + ox "0.6.9" + ws "8.18.1" viem@2.x, viem@^2.1.1, viem@^2.21.44: version "2.23.2" @@ -20583,6 +20596,11 @@ ws@8.17.1, ws@~8.17.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== +ws@8.18.1: + version "8.18.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb" + integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w== + xdeployer@3.1.6: version "3.1.6" resolved "https://registry.yarnpkg.com/xdeployer/-/xdeployer-3.1.6.tgz#91b3061bd814bf65456782ea4bf5895bc382847c" From 35247818db624189d4b2099f0480d2bf43e0d369 Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:40:15 +0100 Subject: [PATCH 09/29] [HUMAN App] chore: remove unused hook (#3178) --- .../get-on-chain-registered-address.ts | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/get-on-chain-registered-address.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/get-on-chain-registered-address.ts b/packages/apps/human-app/frontend/src/modules/worker/services/get-on-chain-registered-address.ts deleted file mode 100644 index d4a6f25467..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/services/get-on-chain-registered-address.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable camelcase -- ... */ -import { z } from 'zod'; -import { useQuery } from '@tanstack/react-query'; -import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; -import { ethKVStoreGetKycData } from '@/modules/smart-contracts/EthKVStore/eth-kv-store-get-kyc-data'; -import { getContractAddress } from '@/modules/smart-contracts/get-contract-address'; -import { useWalletConnect } from '@/shared/contexts/wallet-connect'; - -export interface RegisterAddressPayload { - address: string; -} - -export const RegisterAddressSuccessSchema = z.object({ - signed_address: z.string(), -}); - -export type RegisterAddressSuccess = z.infer< - typeof RegisterAddressSuccessSchema ->; - -export function useGetOnChainRegisteredAddress() { - const { user } = useAuthenticatedUser(); - const { address } = useWalletConnect(); - - return useQuery({ - queryFn: async () => { - const contractAddress = getContractAddress({ - contractName: 'EthKVStore', - }); - - const registeredAddressOnChain = await ethKVStoreGetKycData({ - contractAddress, - accountAddress: user.wallet_address ?? address ?? '', - kycKey: `KYC-${user.reputation_network}`, - }); - - return registeredAddressOnChain; - }, - retry: 0, - refetchInterval: 0, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - queryKey: [user.wallet_address, user.reputation_network, address], - }); -} From e3561d967c01694055073b7e6b8184b14867435f Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:41:39 +0100 Subject: [PATCH 10/29] [HUMAN App] chore: move start kyc mutation hook to module (#3177) --- .../hooks/use-start-kyc-mutation.ts} | 0 .../frontend/src/modules/worker/profile/hooks/use-start-kyc.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/apps/human-app/frontend/src/modules/worker/{services/get-kyc-session-id.ts => profile/hooks/use-start-kyc-mutation.ts} (100%) diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/get-kyc-session-id.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-kyc-mutation.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/services/get-kyc-session-id.ts rename to packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-kyc-mutation.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-kyc.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-kyc.ts index 6ab0781f4b..08c93f14ab 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-kyc.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-kyc.ts @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback } from 'react'; -import { useKycStartMutation } from '@/modules/worker/services/get-kyc-session-id'; import { useKycErrorNotifications } from '@/modules/worker/hooks/use-kyc-notification'; import { FetchError } from '@/api/fetcher'; +import { useKycStartMutation } from './use-start-kyc-mutation'; export function useStartKyc() { const [isKYCInProgress, setIsKYCInProgress] = useState(false); From 075adaa6cd91a4b0cd28f3b6fba2c05916db6034 Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:43:01 +0100 Subject: [PATCH 11/29] [HUMAN App] chore: move solve hcaptcha hook to module (#3175) --- .../worker/hcaptcha-labeling/hcaptcha-labeling.page.tsx | 7 +++++-- .../src/modules/worker/hcaptcha-labeling/hooks/index.ts | 1 + .../hooks/use-solve-hcaptcha-mutation.ts} | 0 3 files changed, 6 insertions(+), 2 deletions(-) rename packages/apps/human-app/frontend/src/modules/worker/{services/solve-hcaptcha.ts => hcaptcha-labeling/hooks/use-solve-hcaptcha-mutation.ts} (100%) diff --git a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hcaptcha-labeling.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hcaptcha-labeling.page.tsx index e042ca0c2d..66fce91d97 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hcaptcha-labeling.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hcaptcha-labeling.page.tsx @@ -10,7 +10,6 @@ import { breakpoints } from '@/shared/styles/breakpoints'; import { Counter } from '@/shared/components/ui/counter'; import { getErrorMessageForError } from '@/shared/errors'; import { getTomorrowDate } from '@/shared/helpers/date'; -import { useSolveHCaptchaMutation } from '@/modules/worker/services/solve-hcaptcha'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; import { useHCaptchaLabelingNotifications } from '@/modules/worker/hooks/use-hcaptcha-labeling-notifications'; import { useColorMode } from '@/shared/contexts/color-mode'; @@ -20,7 +19,11 @@ import { PageCardLoader, PageCardError, } from '@/shared/components/ui/page-card'; -import { useHCaptchaUserStats, useDailyHmtSpent } from './hooks'; +import { + useHCaptchaUserStats, + useDailyHmtSpent, + useSolveHCaptchaMutation, +} from './hooks'; export function HcaptchaLabelingPage() { const { colorPalette, isDarkMode } = useColorMode(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/index.ts index 2029fc304c..a825bac5de 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/index.ts @@ -1,3 +1,4 @@ export * from './use-daily-hmt-spent'; export * from './use-hcaptcha-user-stats'; export * from './enable-hcaptcha-labeling'; +export * from './use-solve-hcaptcha-mutation'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/solve-hcaptcha.ts b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/use-solve-hcaptcha-mutation.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/services/solve-hcaptcha.ts rename to packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/hooks/use-solve-hcaptcha-mutation.ts From afedd0c9abc43eccd55b6bd1b2555d2ea2912557 Mon Sep 17 00:00:00 2001 From: adrian-oleskiewicz Date: Tue, 11 Mar 2025 16:11:39 +0100 Subject: [PATCH 12/29] [HUMAN App] refactor: available jobs in worker module (#2996) --- .../available-jobs-job-type-filter.tsx | 32 --- .../desktop/available-jobs-table.tsx | 268 ------------------ .../available-jobs-job-type-filter-mobile.tsx | 35 --- .../available-jobs-network-filter-mobile.tsx | 37 --- .../mobile/available-jobs-table-mobile.tsx | 178 ------------ .../src/modules/worker/hooks/index.ts | 1 + .../hooks/use-get-oracles-notifications.tsx | 18 +- .../hooks/use-get-oracles.ts | 4 +- .../components/oracles-table-desktop.tsx | 2 +- .../components/oracles-table-item-mobile.tsx | 4 +- .../oracles-table-job-types-select.tsx | 4 +- .../components/oracles-table.tsx | 2 +- .../should-navigate-to-registration.ts | 2 +- .../worker/jobs-discovery/hooks/index.ts | 1 - .../hooks/use-oracles-table-columns.tsx | 4 +- .../hooks/use-select-oracle-navigation.ts | 2 +- .../modules/worker/jobs-discovery/index.ts | 2 +- .../available-jobs-drawer-mobile-view.tsx} | 45 ++- .../available-jobs/available-jobs-view.tsx | 23 ++ .../available-jobs-job-type-filter.tsx | 38 +++ .../available-jobs-network-filter.tsx | 30 +- .../available-jobs-reward-amount-sort.tsx | 16 +- .../desktop/available-jobs-table-desktop.tsx | 106 +++++++ .../components/desktop/index.ts | 1 + .../jobs/available-jobs/components/index.ts | 3 + ...ailable-jobs-assign-job-button-mobile.tsx} | 12 +- .../mobile/available-jobs-list-mobile.tsx | 137 +++++++++ ...ailable-jobs-reward-amount-sort-mobile.tsx | 24 +- .../mobile/available-jobs-table-mobile.tsx | 42 +++ .../available-jobs/components/mobile/index.ts | 2 + .../worker/jobs/available-jobs/hooks/index.ts | 3 + .../available-jobs/hooks}/use-assign-job.ts | 0 .../hooks/use-available-jobs-pagination.tsx | 33 +++ .../hooks/use-get-available-jobs-columns.tsx | 155 ++++++++++ .../hooks/use-get-available-jobs-data.ts} | 5 +- .../worker/jobs/available-jobs/index.ts | 2 + .../escrow-address-search-form.tsx | 0 .../jobs => jobs/components}/evm-address.tsx | 0 .../modules/worker/jobs/components/index.ts | 8 + .../components}/jobs-tab-panel.tsx | 0 .../components}/my-jobs-data.ts | 15 +- .../components}/my-jobs-table-actions.tsx | 14 +- .../components}/reject-button.tsx | 0 .../components}/reward-amount.tsx | 0 .../jobs => jobs/components}/sorting.tsx | 0 .../src/modules/worker/jobs/hooks/index.ts | 6 + .../{ => jobs}/hooks/use-get-all-networks.ts | 0 .../hooks/use-get-ui-config.ts} | 0 .../hooks/use-job-types-oracles-table.tsx | 7 +- .../hooks/use-jobs-filter-store.tsx | 11 +- .../hooks/use-jobs-notifications.tsx | 0 .../hooks/use-my-jobs-filter-store.tsx | 15 +- .../frontend/src/modules/worker/jobs/index.ts | 1 + .../worker/{views => }/jobs/jobs.page.tsx | 73 ++--- .../jobs/my-jobs/components/desktop/index.ts | 1 + .../desktop/my-jobs-expires-at-sort.tsx | 16 +- .../desktop/my-jobs-job-type-filter.tsx | 18 +- .../desktop/my-jobs-network-filter.tsx | 7 +- .../desktop/my-jobs-reward-amount-sort.tsx | 16 +- .../desktop/my-jobs-status-filter.tsx | 6 +- .../components}/desktop/my-jobs-table.tsx | 34 +-- .../jobs/my-jobs/components/mobile/index.ts | 2 + .../mobile/my-jobs-drawer-mobile.tsx | 20 +- .../mobile/my-jobs-expires-at-sort-mobile.tsx | 22 +- .../mobile/my-jobs-job-type-filter-mobile.tsx | 4 +- .../mobile/my-jobs-list-mobile.tsx} | 41 ++- .../mobile/my-jobs-network-filter-mobile.tsx | 7 +- .../my-jobs-reward-amount-sort-mobile.tsx | 24 +- .../mobile/my-jobs-status-filter-mobile.tsx | 6 +- .../my-jobs/components}/status-chip.tsx | 6 +- .../worker/jobs/my-jobs/hooks/index.ts | 2 + .../my-jobs/hooks}/refresh-tasks.ts | 0 .../my-jobs/hooks}/reject-task.ts | 0 .../worker/jobs/my-jobs/my-jobs-view.tsx | 22 ++ .../my-jobs}/utils/get-chip-status-color.ts | 5 +- .../worker/jobs/my-jobs/utils/index.ts | 1 + .../frontend/src/modules/worker/jobs/types.ts | 24 ++ .../oracle-registration/registration.page.tsx | 2 +- .../available-jobs-table-service-mock.ts | 58 ---- .../mocks/my-jobs-table-service-mock.ts | 58 ---- .../frontend/src/router/router-paths.ts | 2 +- .../human-app/frontend/src/router/routes.tsx | 2 +- .../components/ui/table/table-header-cell.tsx | 3 +- .../components/ui}/text-header-with-icon.tsx | 0 .../frontend/src/shared/hooks/index.ts | 8 + .../src/shared/hooks/use-combine-pages.ts | 21 ++ 86 files changed, 880 insertions(+), 981 deletions(-) delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-job-type-filter.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-table.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-job-type-filter-mobile.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-network-filter-mobile.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-table-mobile.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/hooks/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{jobs-discovery => }/hooks/use-get-oracles.ts (94%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/available-jobs/mobile/available-jobs-drawer-mobile.tsx => jobs/available-jobs/available-jobs-drawer-mobile-view.tsx} (72%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-view.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/available-jobs/desktop => jobs/available-jobs/components}/available-jobs-network-filter.tsx (53%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/available-jobs/desktop => jobs/available-jobs/components}/available-jobs-reward-amount-sort.tsx (70%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/available-jobs-table-desktop.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/index.ts create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/available-jobs/mobile/available-jobs-assign-job-button.tsx => jobs/available-jobs/components/mobile/available-jobs-assign-job-button-mobile.tsx} (81%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-list-mobile.tsx rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/available-jobs => jobs/available-jobs/components}/mobile/available-jobs-reward-amount-sort-mobile.tsx (61%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/index.ts create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{services => jobs/available-jobs/hooks}/use-assign-job.ts (100%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-pagination.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx rename packages/apps/human-app/frontend/src/modules/worker/{services/available-jobs-data.ts => jobs/available-jobs/hooks/use-get-available-jobs-data.ts} (91%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/components}/escrow-address-search-form.tsx (100%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/components}/evm-address.tsx (100%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/components}/jobs-tab-panel.tsx (100%) rename packages/apps/human-app/frontend/src/modules/worker/{services => jobs/components}/my-jobs-data.ts (86%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/components}/my-jobs-table-actions.tsx (78%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/components}/reject-button.tsx (100%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/components}/reward-amount.tsx (100%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/components}/sorting.tsx (100%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{ => jobs}/hooks/use-get-all-networks.ts (100%) rename packages/apps/human-app/frontend/src/modules/worker/{services/get-ui-config.ts => jobs/hooks/use-get-ui-config.ts} (100%) rename packages/apps/human-app/frontend/src/modules/worker/{ => jobs}/hooks/use-job-types-oracles-table.tsx (78%) rename packages/apps/human-app/frontend/src/modules/worker/{ => jobs}/hooks/use-jobs-filter-store.tsx (90%) rename packages/apps/human-app/frontend/src/modules/worker/{ => jobs}/hooks/use-jobs-notifications.tsx (100%) rename packages/apps/human-app/frontend/src/modules/worker/{ => jobs}/hooks/use-my-jobs-filter-store.tsx (88%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{views => }/jobs/jobs.page.tsx (68%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/desktop/my-jobs-expires-at-sort.tsx (70%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/desktop/my-jobs-job-type-filter.tsx (76%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/desktop/my-jobs-network-filter.tsx (74%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/desktop/my-jobs-reward-amount-sort.tsx (70%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/desktop/my-jobs-status-filter.tsx (76%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/desktop/my-jobs-table.tsx (87%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/mobile/my-jobs-drawer-mobile.tsx (87%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/mobile/my-jobs-expires-at-sort-mobile.tsx (62%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/mobile/my-jobs-job-type-filter-mobile.tsx (86%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs/mobile/my-jobs-table-mobile.tsx => jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx} (83%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/mobile/my-jobs-network-filter-mobile.tsx (75%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/mobile/my-jobs-reward-amount-sort-mobile.tsx (60%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs/my-jobs => jobs/my-jobs/components}/mobile/my-jobs-status-filter-mobile.tsx (77%) rename packages/apps/human-app/frontend/src/modules/worker/{components/jobs => jobs/my-jobs/components}/status-chip.tsx (76%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/index.ts rename packages/apps/human-app/frontend/src/modules/worker/{services => jobs/my-jobs/hooks}/refresh-tasks.ts (100%) rename packages/apps/human-app/frontend/src/modules/worker/{services => jobs/my-jobs/hooks}/reject-task.ts (100%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/my-jobs-view.tsx rename packages/apps/human-app/frontend/src/modules/worker/{ => jobs/my-jobs}/utils/get-chip-status-color.ts (82%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/index.ts create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/types.ts delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/mocks/available-jobs-table-service-mock.ts delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/mocks/my-jobs-table-service-mock.ts rename packages/apps/human-app/frontend/src/{modules/worker/components/jobs => shared/components/ui}/text-header-with-icon.tsx (100%) create mode 100644 packages/apps/human-app/frontend/src/shared/hooks/index.ts create mode 100644 packages/apps/human-app/frontend/src/shared/hooks/use-combine-pages.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-job-type-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-job-type-filter.tsx deleted file mode 100644 index 166cf584ff..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-job-type-filter.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable camelcase --- ... */ -import { useTranslation } from 'react-i18next'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; -import { JOB_TYPES } from '@/shared/consts'; - -export function AvailableJobsJobTypeFilter() { - const { t } = useTranslation(); - const { setFilterParams, filterParams } = useJobsFilterStore(); - - return ( - { - setFilterParams({ - ...filterParams, - job_type: undefined, - }); - }} - filteringOptions={JOB_TYPES.map((jobType) => ({ - name: t(`jobTypeLabels.${jobType}`), - option: jobType, - }))} - isChecked={(option) => option === filterParams.job_type} - setFiltering={(jobType) => { - setFilterParams({ - ...filterParams, - job_type: jobType, - }); - }} - /> - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-table.tsx b/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-table.tsx deleted file mode 100644 index c18401185b..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-table.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/* eslint-disable camelcase -- ... */ -import type { MRT_ColumnDef } from 'material-react-table'; -import { - MaterialReactTable, - useMaterialReactTable, -} from 'material-react-table'; -import { t } from 'i18next'; -import { useEffect, useMemo, useState } from 'react'; -import { Grid } from '@mui/material'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; -import { - useGetAvailableJobsData, - type AvailableJob, -} from '@/modules/worker/services/available-jobs-data'; -import { useAssignJobMutation } from '@/modules/worker/services/use-assign-job'; -import { EvmAddress } from '@/modules/worker/components/jobs/evm-address'; -import { RewardAmount } from '@/modules/worker/components/jobs/reward-amount'; -import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; -import { Chip } from '@/shared/components/ui/chip'; -import { useJobsNotifications } from '@/modules/worker/hooks/use-jobs-notifications'; -import { TableButton } from '@/shared/components/ui/table-button'; -import { TableHeaderCell } from '@/shared/components/ui/table/table-header-cell'; -import { AvailableJobsNetworkFilter } from '@/modules/worker/components/jobs/available-jobs/desktop/available-jobs-network-filter'; -import { AvailableJobsRewardAmountSort } from '@/modules/worker/components/jobs/available-jobs/desktop/available-jobs-reward-amount-sort'; -import { AvailableJobsJobTypeFilter } from '@/modules/worker/components/jobs/available-jobs/desktop/available-jobs-job-type-filter'; -import { useColorMode } from '@/shared/contexts/color-mode'; -import { createTableDarkMode } from '@/shared/styles/create-table-dark-mode'; -import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; -import { EscrowAddressSearchForm } from '@/modules/worker/components/jobs/escrow-address-search-form'; - -interface AvailableJobsTableProps { - chainIdsEnabled: number[]; -} - -export type AvailableJobsTableData = AvailableJob & { - rewardTokenInfo: { - reward_amount?: string; - reward_token?: string; - }; -}; - -const getColumns = ( - chainIdsEnabled: number[] -): MRT_ColumnDef[] => [ - { - accessorKey: 'job_description', - header: t('worker.jobs.jobDescription'), - size: 100, - enableSorting: false, - }, - { - accessorKey: 'escrow_address', - header: t('worker.jobs.escrowAddress'), - size: 100, - enableSorting: false, - Cell: (props) => { - return ; - }, - }, - { - accessorKey: 'chain_id', - header: t('worker.jobs.network'), - size: 100, - enableSorting: false, - Cell: (props) => { - return getNetworkName(props.row.original.chain_id); - }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - - } - /> - ); - }, - }), - }, - { - accessorKey: 'reward_amount', - header: t('worker.jobs.rewardAmount'), - size: 100, - enableSorting: false, - Cell: (props) => { - const { reward_amount, reward_token } = props.row.original; - return ( - - ); - }, - muiTableHeadCellProps: () => ({ - component: (props) => ( - } - /> - ), - }), - }, - { - accessorKey: 'job_type', - header: t('worker.jobs.jobType'), - size: 200, - enableSorting: false, - Cell: ({ row }) => { - const label = t(`jobTypeLabels.${row.original.job_type as JobType}`); - return ; - }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), - }, - { - accessorKey: 'escrow_address', - id: 'selectJobAction', - header: '', - size: 100, - enableSorting: false, - Cell: (props) => { - const { escrow_address, chain_id } = props.row.original; - const { onJobAssignmentError, onJobAssignmentSuccess } = - useJobsNotifications(); - const { mutate: assignJobMutation, isPending } = useAssignJobMutation( - { - onSuccess: onJobAssignmentSuccess, - onError: onJobAssignmentError, - }, - [`assignJob-${escrow_address}`] - ); - - return ( - - { - assignJobMutation({ escrow_address, chain_id }); - }} - sx={{ - width: '94px', - }} - > - {t('worker.jobs.selectJob')} - - - ); - }, - }, -]; - -export function AvailableJobsTable({ - chainIdsEnabled, -}: Readonly) { - const { colorPalette, isDarkMode } = useColorMode(); - const { - setSearchEscrowAddress, - setPageParams, - filterParams, - resetFilterParams, - } = useJobsFilterStore(); - const { data: tableData, status: tableStatus } = useGetAvailableJobsData(); - const memoizedTableDataResults = useMemo( - () => tableData?.results ?? [], - [tableData?.results] - ); - - const [paginationState, setPaginationState] = useState({ - pageIndex: 0, - pageSize: 5, - }); - - useEffect(() => { - if (!(paginationState.pageSize === 5 || paginationState.pageSize === 10)) - return; - setPageParams(paginationState.pageIndex, paginationState.pageSize); - }, [paginationState, setPageParams]); - - useEffect(() => { - setPaginationState({ - pageIndex: filterParams.page, - pageSize: filterParams.page_size, - }); - }, [filterParams.page, filterParams.page_size]); - - useEffect(() => { - return () => { - resetFilterParams(); - }; - }, [resetFilterParams]); - - const columns: MRT_ColumnDef[] = getColumns(chainIdsEnabled); - - const table = useMaterialReactTable({ - columns, - data: memoizedTableDataResults, - state: { - isLoading: tableStatus === 'pending', - showAlertBanner: tableStatus === 'error', - pagination: paginationState, - }, - enablePagination: Boolean(tableData?.total_pages), - manualPagination: true, - onPaginationChange: setPaginationState, - muiPaginationProps: { - SelectProps: { - sx: { - '.MuiSelect-icon': { - ':hover': { - backgroundColor: 'blue', - }, - fill: colorPalette.text.primary, - }, - }, - }, - rowsPerPageOptions: [5, 10], - }, - pageCount: tableData?.total_pages ?? -1, - rowCount: tableData?.total_results, - enableColumnActions: false, - enableColumnFilters: false, - enableSorting: true, - manualSorting: true, - renderTopToolbar: () => ( - { - setSearchEscrowAddress(address); - }} - /> - ), - muiTableHeadCellProps: { - sx: { - borderColor: colorPalette.paper.text, - }, - }, - muiTableBodyCellProps: { - sx: { - borderColor: colorPalette.paper.text, - }, - }, - muiTablePaperProps: { - sx: { - boxShadow: '0px 2px 2px 0px #E9EBFA80', - }, - }, - ...(isDarkMode ? createTableDarkMode(colorPalette) : {}), - }); - - return ; -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-job-type-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-job-type-filter-mobile.tsx deleted file mode 100644 index d4ad256708..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-job-type-filter-mobile.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable camelcase --- ... */ -import { useTranslation } from 'react-i18next'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; -import { JOB_TYPES } from '@/shared/consts'; - -export function AvailableJobsJobTypeFilterMobile() { - const { t } = useTranslation(); - const { setFilterParams, filterParams } = useJobsFilterStore(); - - return ( - { - setFilterParams({ - ...filterParams, - job_type: undefined, - page: 0, - }); - }} - filteringOptions={JOB_TYPES.map((jobType) => ({ - name: t(`jobTypeLabels.${jobType}`), - option: jobType, - }))} - isChecked={(option) => option === filterParams.job_type} - isMobile={false} - setFiltering={(jobType) => { - setFilterParams({ - ...filterParams, - job_type: jobType, - page: 0, - }); - }} - /> - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-network-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-network-filter-mobile.tsx deleted file mode 100644 index a521fa3f75..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-network-filter-mobile.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable camelcase --- ... */ -import { useGetAllNetworks } from '@/modules/worker/hooks/use-get-all-networks'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; - -interface AvailableJobsNetworkFilterProps { - chainIdsEnabled: number[]; -} - -export function AvailableJobsNetworkFilterMobile({ - chainIdsEnabled, -}: AvailableJobsNetworkFilterProps) { - const { setFilterParams, filterParams } = useJobsFilterStore(); - const { allNetworks } = useGetAllNetworks(chainIdsEnabled); - - return ( - { - setFilterParams({ - ...filterParams, - chain_id: undefined, - page: 0, - }); - }} - filteringOptions={allNetworks} - isChecked={(option) => option === filterParams.chain_id} - isMobile={false} - setFiltering={(chainId) => { - setFilterParams({ - ...filterParams, - chain_id: chainId, - page: 0, - }); - }} - /> - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-table-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-table-mobile.tsx deleted file mode 100644 index fc503eb452..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-table-mobile.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/* eslint-disable camelcase -- ... */ -import { Grid, List, Paper, Stack, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; -import { Button } from '@/shared/components/ui/button'; -import { FiltersButtonIcon } from '@/shared/components/ui/icons'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; -import { Alert } from '@/shared/components/ui/alert'; -import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; -import { getErrorMessageForError } from '@/shared/errors'; -import { Loader } from '@/shared/components/ui/loader'; -import { EvmAddress } from '@/modules/worker/components/jobs/evm-address'; -import { Chip } from '@/shared/components/ui/chip'; -import { RewardAmount } from '@/modules/worker/components/jobs/reward-amount'; -import { ListItem } from '@/shared/components/ui/list-item'; -import { useColorMode } from '@/shared/contexts/color-mode'; -import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; -import { EscrowAddressSearchForm } from '@/modules/worker/components/jobs/escrow-address-search-form'; -import { AvailableJobsAssignJobButton } from '@/modules/worker/components/jobs/available-jobs/mobile/available-jobs-assign-job-button'; -import { - type AvailableJob, - useInfiniteAvailableJobsQuery, -} from '@/modules/worker/services/available-jobs-data'; - -interface AvailableJobsTableMobileProps { - setIsMobileFilterDrawerOpen: Dispatch>; -} - -export function AvailableJobsTableMobile({ - setIsMobileFilterDrawerOpen, -}: Readonly) { - const { colorPalette } = useColorMode(); - const [allPages, setAllPages] = useState([]); - - const { - data: tableData, - status: tableStatus, - isError: isTableError, - error: tableError, - fetchNextPage, - hasNextPage, - } = useInfiniteAvailableJobsQuery(); - const { filterParams, setPageParams, resetFilterParams } = - useJobsFilterStore(); - const { t } = useTranslation(); - const { setSearchEscrowAddress } = useJobsFilterStore(); - - useEffect(() => { - if (!tableData) return; - const pagesFromRes = tableData.pages.flatMap((pages) => pages.results); - if (filterParams.page === 0) { - setAllPages(pagesFromRes); - } else { - setAllPages((state) => [...state, ...pagesFromRes]); - } - }, [tableData, filterParams.page]); - - useEffect(() => { - return () => { - resetFilterParams(); - }; - }, [resetFilterParams]); - - return ( - <> - - - - {isTableError ? ( - - {getErrorMessageForError(tableError)} - - ) : null} - {tableStatus === 'pending' ? ( - - - - ) : null} - {allPages.map((d) => ( - - - - - - - {d.job_description} - - - - - - - - - - - - - - - {getNetworkName(d.chain_id)} - - - - - - - - - - - - - - - ))} - {hasNextPage ? ( - - ) : null} - - - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/index.ts new file mode 100644 index 0000000000..b6aa06f63c --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-get-oracles'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-oracles-notifications.tsx b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-oracles-notifications.tsx index f29fe868e2..65d7280f15 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-oracles-notifications.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-oracles-notifications.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { TopNotificationType, useNotification, @@ -8,13 +9,16 @@ import type { ResponseError } from '@/shared/types/global.type'; export function useGetOraclesNotifications() { const { showNotification } = useNotification(); - const onError = (error: ResponseError) => { - showNotification({ - type: TopNotificationType.WARNING, - message: getErrorMessageForError(error), - durationMs: 5000, - }); - }; + const onError = useCallback( + (error: ResponseError) => { + showNotification({ + type: TopNotificationType.WARNING, + message: getErrorMessageForError(error), + durationMs: 5000, + }); + }, + [showNotification] + ); return { onError }; } diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-get-oracles.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-oracles.ts similarity index 94% rename from packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-get-oracles.ts rename to packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-oracles.ts index a4f888de54..f15e55945d 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-get-oracles.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-oracles.ts @@ -3,10 +3,10 @@ import { z } from 'zod'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; -import { useJobsTypesOraclesFilter } from '@/modules/worker/hooks/use-job-types-oracles-table'; import { stringifyUrlQueryObject } from '@/shared/helpers/transfomers'; import { env } from '@/shared/env'; import { MainnetChains, TestnetChains } from '@/modules/smart-contracts/chains'; +import { useJobsTypesOraclesFilterStore } from '../jobs/hooks'; const OracleSchema = z.object({ address: z.string(), @@ -94,7 +94,7 @@ async function getOracles({ } export function useGetOracles() { - const { selected_job_types } = useJobsTypesOraclesFilter(); + const { selected_job_types } = useJobsTypesOraclesFilterStore(); return useQuery({ queryFn: ({ signal }) => getOracles({ selected_job_types, signal }), queryKey: ['oracles', selected_job_types], diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-desktop.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-desktop.tsx index 3314b469ee..7111c46d80 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-desktop.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-desktop.tsx @@ -5,7 +5,7 @@ import { import { createTableDarkMode } from '@/shared/styles/create-table-dark-mode'; import { useColorMode } from '@/shared/contexts/color-mode'; import { useOraclesTableColumns } from '../hooks/use-oracles-table-columns'; -import { type Oracle } from '../hooks'; +import { type Oracle } from '../../hooks'; interface OraclesTableDesktopProps { isOraclesDataPending: boolean; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-item-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-item-mobile.tsx index 44566c107d..e68f086405 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-item-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-item-mobile.tsx @@ -2,12 +2,12 @@ import { Grid, Paper, type SxProps, Typography } from '@mui/material'; import { t } from 'i18next'; import { Chips } from '@/shared/components/ui/chips'; import { TableButton } from '@/shared/components/ui/table-button'; -import { EvmAddress } from '@/modules/worker/components/jobs/evm-address'; import { ListItem } from '@/shared/components/ui/list-item'; import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { type Oracle } from '../hooks'; import { useSelectOracleNavigation } from '../hooks/use-select-oracle-navigation'; +import { EvmAddress } from '../../jobs/components'; +import { type Oracle } from '../../hooks'; interface OraclesTableItemMobileProps { oracle: Oracle; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-job-types-select.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-job-types-select.tsx index ef4499bf78..d41c0a9f36 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-job-types-select.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table-job-types-select.tsx @@ -2,14 +2,14 @@ import { t } from 'i18next'; import { FormProvider, useForm } from 'react-hook-form'; import { useEffect } from 'react'; import { Grid } from '@mui/material'; -import { useJobsTypesOraclesFilter } from '@/modules/worker/hooks/use-job-types-oracles-table'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; import { MultiSelect } from '@/shared/components/data-entry/multi-select'; import { JOB_TYPES } from '@/shared/consts'; +import { useJobsTypesOraclesFilterStore } from '../../jobs/hooks'; export function OraclesTableJobTypesSelect() { const isMobile = useIsMobile(); - const { selectJobType } = useJobsTypesOraclesFilter(); + const { selectJobType } = useJobsTypesOraclesFilterStore(); const methods = useForm<{ jobType: string[] }>({ defaultValues: { jobType: [], diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx index b4cd800329..2906bc2129 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/components/oracles-table.tsx @@ -6,7 +6,7 @@ import { Loader } from '@/shared/components/ui/loader'; import { NoRecords } from '@/shared/components/ui/no-records'; import { useGetOraclesNotifications } from '@/modules/worker/hooks/use-get-oracles-notifications'; import { PageCardError } from '@/shared/components/ui/page-card'; -import { useGetOracles } from '../hooks'; +import { useGetOracles } from '../../hooks'; import { OraclesTableItemMobile } from './oracles-table-item-mobile'; import { OraclesTableDesktop } from './oracles-table-desktop'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts index 90af1f932c..4390786d84 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts @@ -1,4 +1,4 @@ -import { type Oracle } from '../hooks'; +import { type Oracle } from '../../hooks'; interface RegistrationResult { oracle_addresses: string[]; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/index.ts index ba8a564805..e3515417de 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/index.ts @@ -1,2 +1 @@ -export * from './use-get-oracles'; export * from './use-oracles-table-columns'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-oracles-table-columns.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-oracles-table-columns.tsx index 4dbafaec6d..ab9386d8bc 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-oracles-table-columns.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-oracles-table-columns.tsx @@ -2,11 +2,11 @@ import { useMemo } from 'react'; import { t } from 'i18next'; import { Grid } from '@mui/material'; import type { MRT_ColumnDef } from 'material-react-table'; -import { EvmAddress } from '@/modules/worker/components/jobs/evm-address'; import { Chips } from '@/shared/components/ui/chips'; import { TableButton } from '@/shared/components/ui/table-button'; import { type JobType } from '@/modules/smart-contracts/EthKVStore/config'; -import { type Oracle } from './use-get-oracles'; +import { EvmAddress } from '../../jobs/components'; +import { type Oracle } from '../../hooks'; import { useSelectOracleNavigation } from './use-select-oracle-navigation'; export const useOraclesTableColumns = (): MRT_ColumnDef[] => { diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-select-oracle-navigation.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-select-oracle-navigation.ts index f3d90b872e..48a39887e8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-select-oracle-navigation.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/hooks/use-select-oracle-navigation.ts @@ -3,8 +3,8 @@ import { useCallback, useMemo } from 'react'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; import { routerPaths } from '@/router/router-paths'; import { shouldNavigateToRegistration, isHCaptchaOracle } from '../helpers'; +import { type Oracle } from '../../hooks'; import { useGetRegistrationDataInOracles } from './use-get-registration-data-oracles'; -import { type Oracle } from './use-get-oracles'; const getHCaptchaPagePath = (siteKey: string | null | undefined): string => siteKey diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/index.ts index e171b27f6d..0011c13be6 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/index.ts @@ -1,2 +1,2 @@ export * from './jobs-discovery.page'; -export * from './hooks/use-get-oracles'; +export * from './hooks/use-get-registration-data-oracles'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-drawer-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-drawer-mobile-view.tsx similarity index 72% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-drawer-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-drawer-mobile-view.tsx index 8fc33beddc..29373aa307 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-drawer-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-drawer-mobile-view.tsx @@ -6,24 +6,30 @@ import { useTranslation } from 'react-i18next'; import CloseIcon from '@mui/icons-material/Close'; import type { Dispatch, SetStateAction } from 'react'; import { HumanLogoIcon } from '@/shared/components/ui/icons'; -import { AvailableJobsNetworkFilterMobile } from '@/modules/worker/components/jobs/available-jobs/mobile/available-jobs-network-filter-mobile'; import { useHandleMainNavIconClick } from '@/shared/hooks/use-handle-main-nav-icon-click'; -import { AvailableJobsJobTypeFilterMobile } from '@/modules/worker/components/jobs/available-jobs/mobile/available-jobs-job-type-filter-mobile'; -import { AvailableJobsRewardAmountSortMobile } from '@/modules/worker/components/jobs/available-jobs/mobile/available-jobs-reward-amount-sort-mobile'; import { useColorMode } from '@/shared/contexts/color-mode'; +import { + AvailableJobsNetworkFilter, + AvailableJobsJobTypeFilter, +} from './components'; +import { AvailableJobsRewardAmountSortMobile } from './components/mobile'; -interface DrawerMobileProps { +interface DrawerMobileViewProps { setIsMobileFilterDrawerOpen: Dispatch>; chainIdsEnabled: number[]; } -export function AvailableJobsDrawerMobile({ +export function AvailableJobsDrawerMobileView({ setIsMobileFilterDrawerOpen, chainIdsEnabled, -}: DrawerMobileProps) { +}: Readonly) { const handleMainNavIconClick = useHandleMainNavIconClick(); const { colorPalette } = useColorMode(); const { t } = useTranslation(); + const handleCloseDrawer = () => { + setIsMobileFilterDrawerOpen(false); + }; + return ( @@ -58,19 +64,12 @@ export function AvailableJobsDrawerMobile({ zIndex: '999999', }} > - { - handleMainNavIconClick(); - }} - > + { - setIsMobileFilterDrawerOpen(false); - }} + onClick={handleCloseDrawer} sx={{ zIndex: '99999999', marginRight: '15px', @@ -105,12 +104,8 @@ export function AvailableJobsDrawerMobile({ {t('worker.jobs.network')} - - + + {t('worker.jobs.jobType')} - - + + diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-view.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-view.tsx new file mode 100644 index 0000000000..dd732ea662 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/available-jobs-view.tsx @@ -0,0 +1,23 @@ +import { useIsMobile } from '@/shared/hooks/use-is-mobile'; +import { AvailableJobsTableDesktop } from './components/desktop'; +import { AvailableJobsTableMobile } from './components/mobile'; + +interface AvailableJobsTableView { + handleOpenMobileFilterDrawer: () => void; + chainIdsEnabled: number[]; +} + +export function AvailableJobsView({ + handleOpenMobileFilterDrawer, + chainIdsEnabled, +}: AvailableJobsTableView) { + const isMobile = useIsMobile(); + + return isMobile ? ( + + ) : ( + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx new file mode 100644 index 0000000000..38455484ef --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx @@ -0,0 +1,38 @@ +/* eslint-disable camelcase --- ... */ +import { useTranslation } from 'react-i18next'; +import { useMemo } from 'react'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { JOB_TYPES } from '@/shared/consts'; +import { useJobsFilterStore } from '../../hooks'; + +export function AvailableJobsJobTypeFilter({ isMobile = false }) { + const { t } = useTranslation(); + const { setFilterParams, filterParams } = useJobsFilterStore(); + + const filteringOptions = useMemo( + () => + JOB_TYPES.map((jobType) => ({ + name: t(`jobTypeLabels.${jobType}`), + option: jobType, + })), + [t] + ); + + const handleClear = () => { + setFilterParams({ job_type: undefined }); + }; + + const handleFilterChange = (jobType: string) => { + setFilterParams({ job_type: jobType }); + }; + + return ( + option === filterParams.job_type} + isMobile={isMobile} + setFiltering={handleFilterChange} + /> + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-network-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-network-filter.tsx similarity index 53% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-network-filter.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-network-filter.tsx index 15e040fcbe..47f5dce0ae 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-network-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-network-filter.tsx @@ -1,34 +1,34 @@ /* eslint-disable camelcase --- ... */ import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; -import { useGetAllNetworks } from '@/modules/worker/hooks/use-get-all-networks'; +import { useGetAllNetworks, useJobsFilterStore } from '../../hooks'; interface AvailableJobsNetworkFilterProps { chainIdsEnabled: number[]; + isMobile?: boolean; } export function AvailableJobsNetworkFilter({ chainIdsEnabled, -}: AvailableJobsNetworkFilterProps) { + isMobile = false, +}: Readonly) { const { setFilterParams, filterParams } = useJobsFilterStore(); const { allNetworks } = useGetAllNetworks(chainIdsEnabled); + const handleClear = () => { + setFilterParams({ chain_id: undefined }); + }; + + const handleFilterChange = (chainId: number) => { + setFilterParams({ chain_id: chainId }); + }; + return ( { - setFilterParams({ - ...filterParams, - chain_id: undefined, - }); - }} + clear={handleClear} filteringOptions={allNetworks} isChecked={(option) => option === filterParams.chain_id} - setFiltering={(chainId) => { - setFilterParams({ - ...filterParams, - chain_id: chainId, - }); - }} + isMobile={isMobile} + setFiltering={handleFilterChange} /> ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-reward-amount-sort.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-reward-amount-sort.tsx similarity index 70% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-reward-amount-sort.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-reward-amount-sort.tsx index 003ab5fa36..8f65c5f9fe 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/desktop/available-jobs-reward-amount-sort.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-reward-amount-sort.tsx @@ -1,24 +1,23 @@ /* eslint-disable camelcase --- ... */ import { t } from 'i18next'; import { Sorting } from '@/shared/components/ui/table/table-header-menu.tsx/sorting'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; +import { useJobsFilterStore } from '../../hooks'; +import { SortDirection, SortField } from '../../types'; export function AvailableJobsRewardAmountSort() { - const { setFilterParams, filterParams } = useJobsFilterStore(); + const { setFilterParams } = useJobsFilterStore(); const sortAscRewardAmount = () => { setFilterParams({ - ...filterParams, - sort_field: 'reward_amount', - sort: 'asc', + sort_field: SortField.REWARD_AMOUNT, + sort: SortDirection.ASC, }); }; const sortDescRewardAmount = () => { setFilterParams({ - ...filterParams, - sort_field: 'reward_amount', - sort: 'desc', + sort_field: SortField.REWARD_AMOUNT, + sort: SortDirection.DESC, }); }; @@ -26,7 +25,6 @@ export function AvailableJobsRewardAmountSort() { { setFilterParams({ - ...filterParams, sort_field: undefined, sort: undefined, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/available-jobs-table-desktop.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/available-jobs-table-desktop.tsx new file mode 100644 index 0000000000..b20ecf2e28 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/available-jobs-table-desktop.tsx @@ -0,0 +1,106 @@ +import { + MaterialReactTable, + useMaterialReactTable, +} from 'material-react-table'; +import { t } from 'i18next'; +import { useEffect, useMemo } from 'react'; +import { createTableDarkMode } from '@/shared/styles/create-table-dark-mode'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { useJobsFilterStore } from '../../../hooks'; +import { EscrowAddressSearchForm } from '../../../components'; +import { useGetAvailableJobsData } from '../../hooks/use-get-available-jobs-data'; +import { useGetAvailableJobsColumns } from '../../hooks'; +import { useAvailableJobsPagination } from '../../hooks/use-available-jobs-pagination'; + +interface AvailableJobsTableProps { + chainIdsEnabled: number[]; +} + +export function AvailableJobsTableDesktop({ + chainIdsEnabled, +}: Readonly) { + const { colorPalette, isDarkMode } = useColorMode(); + const { data: tableData, status: tableStatus } = useGetAvailableJobsData(); + const { + setSearchEscrowAddress, + setPageParams, + filterParams, + resetFilterParams, + } = useJobsFilterStore(); + const { paginationState, setPaginationState } = useAvailableJobsPagination({ + setPageParams, + filterParams, + }); + const columns = useGetAvailableJobsColumns(chainIdsEnabled); + + const memoizedTableDataResults = useMemo( + () => tableData?.results ?? [], + [tableData?.results] + ); + + useEffect(() => { + return () => { + resetFilterParams(); + }; + }, [resetFilterParams]); + + const table = useMaterialReactTable({ + columns, + data: memoizedTableDataResults, + state: { + isLoading: tableStatus === 'pending', + showAlertBanner: tableStatus === 'error', + pagination: paginationState, + }, + enablePagination: Boolean(tableData?.total_pages), + manualPagination: true, + onPaginationChange: setPaginationState, + muiPaginationProps: { + SelectProps: { + sx: { + '.MuiSelect-icon': { + ':hover': { + backgroundColor: 'blue', + }, + fill: colorPalette.text.primary, + }, + }, + }, + rowsPerPageOptions: [5, 10], + }, + pageCount: tableData?.total_pages ?? -1, + rowCount: tableData?.total_results, + enableColumnActions: false, + enableColumnFilters: false, + enableSorting: true, + manualSorting: true, + renderTopToolbar: () => ( + { + setSearchEscrowAddress(address); + }} + /> + ), + muiTableHeadCellProps: { + sx: { + borderColor: colorPalette.paper.text, + }, + }, + muiTableBodyCellProps: { + sx: { + borderColor: colorPalette.paper.text, + }, + }, + muiTablePaperProps: { + sx: { + boxShadow: '0px 2px 2px 0px #E9EBFA80', + }, + }, + ...(isDarkMode ? createTableDarkMode(colorPalette) : {}), + }); + + return ; +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/index.ts new file mode 100644 index 0000000000..cbd6a6feea --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/desktop/index.ts @@ -0,0 +1 @@ +export * from './available-jobs-table-desktop'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/index.ts new file mode 100644 index 0000000000..9d9f97fed7 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/index.ts @@ -0,0 +1,3 @@ +export * from './available-jobs-job-type-filter'; +export * from './available-jobs-network-filter'; +export * from './available-jobs-reward-amount-sort'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-assign-job-button.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-assign-job-button-mobile.tsx similarity index 81% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-assign-job-button.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-assign-job-button-mobile.tsx index 260f3d0833..98cfd6cd51 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-assign-job-button.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-assign-job-button-mobile.tsx @@ -1,16 +1,16 @@ import { t } from 'i18next'; +import { TableButton } from '@/shared/components/ui/table-button'; +import { useJobsNotifications } from '../../../hooks'; import { type AssignJobBody, useAssignJobMutation, -} from '@/modules/worker/services/use-assign-job'; -import { TableButton } from '@/shared/components/ui/table-button'; -import { useJobsNotifications } from '@/modules/worker/hooks/use-jobs-notifications'; +} from '../../hooks/use-assign-job'; -export function AvailableJobsAssignJobButton({ +export function AvailableJobsAssignJobButtonMobile({ assignJobPayload, -}: { +}: Readonly<{ assignJobPayload: AssignJobBody; -}) { +}>) { const { onJobAssignmentError, onJobAssignmentSuccess } = useJobsNotifications(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-list-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-list-mobile.tsx new file mode 100644 index 0000000000..73d12ac553 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-list-mobile.tsx @@ -0,0 +1,137 @@ +/* eslint-disable camelcase -- ... */ +import { Grid, List, Paper, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useEffect } from 'react'; +import { Button } from '@/shared/components/ui/button'; +import { Alert } from '@/shared/components/ui/alert'; +import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; +import { Loader } from '@/shared/components/ui/loader'; +import { Chip } from '@/shared/components/ui/chip'; +import { ListItem } from '@/shared/components/ui/list-item'; +import { type JobType } from '@/modules/smart-contracts/EthKVStore/config'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { getErrorMessageForError } from '@/shared/errors'; +import { useCombinePages } from '@/shared/hooks'; +import { useJobsFilterStore } from '../../../hooks'; +import { + useInifiniteGetAvailableJobsData, + type AvailableJob, +} from '../../hooks/use-get-available-jobs-data'; +import { EvmAddress, RewardAmount } from '../../../components'; +import { AvailableJobsAssignJobButtonMobile } from './available-jobs-assign-job-button-mobile'; + +export function AvailableJobsListMobile() { + const { t } = useTranslation(); + const { colorPalette } = useColorMode(); + const { filterParams, setPageParams, resetFilterParams } = + useJobsFilterStore(); + const { + data: tableData, + status: tableStatus, + isError: isTableError, + error: tableError, + fetchNextPage, + hasNextPage, + } = useInifiniteGetAvailableJobsData(); + + const allPages = useCombinePages(tableData, filterParams.page); + + useEffect(() => { + return () => { + resetFilterParams(); + }; + }, [resetFilterParams]); + + return ( + + {isTableError && ( + + {getErrorMessageForError(tableError)} + + )} + {tableStatus === 'pending' && ( + + + + )} + + {allPages.map((d) => ( + + + + + + + {d.job_description} + + + + + + + + + + + + + + + {getNetworkName(d.chain_id)} + + + + + + + + + + + + + + + ))} + {hasNextPage ? ( + + ) : null} + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-reward-amount-sort-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-reward-amount-sort-mobile.tsx similarity index 61% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-reward-amount-sort-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-reward-amount-sort-mobile.tsx index ce6704f8e3..bd16dee084 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/available-jobs/mobile/available-jobs-reward-amount-sort-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-reward-amount-sort-mobile.tsx @@ -2,8 +2,9 @@ import { t } from 'i18next'; import Typography from '@mui/material/Typography'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; -import { Sorting } from '@/modules/worker/components/jobs/sorting'; +import { useJobsFilterStore } from '../../../hooks'; +import { Sorting } from '../../../components'; +import { SortDirection, SortField } from '../../../types'; export function AvailableJobsRewardAmountSortMobile() { const { setFilterParams, filterParams } = useJobsFilterStore(); @@ -17,30 +18,27 @@ export function AvailableJobsRewardAmountSortMobile() { } fromHighestSelected={ - filterParams.sort_field === 'reward_amount' && - filterParams.sort === 'desc' + filterParams.sort_field === SortField.REWARD_AMOUNT && + filterParams.sort === SortDirection.DESC } sortFromHighest={() => { setFilterParams({ - ...filterParams, - sort: 'desc', - sort_field: 'reward_amount', + sort: SortDirection.DESC, + sort_field: SortField.REWARD_AMOUNT, }); }} fromLowestSelected={ - filterParams.sort_field === 'reward_amount' && - filterParams.sort === 'asc' + filterParams.sort_field === SortField.REWARD_AMOUNT && + filterParams.sort === SortDirection.ASC } sortFromLowest={() => { setFilterParams({ - ...filterParams, - sort: 'asc', - sort_field: 'reward_amount', + sort: SortDirection.ASC, + sort_field: SortField.REWARD_AMOUNT, }); }} clear={() => { setFilterParams({ - ...filterParams, sort: undefined, sort_field: undefined, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx new file mode 100644 index 0000000000..e056ed10ce --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/available-jobs-table-mobile.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '@/shared/components/ui/button'; +import { FiltersButtonIcon } from '@/shared/components/ui/icons'; +import { useJobsFilterStore } from '../../../hooks'; +import { EscrowAddressSearchForm } from '../../../components'; +import { AvailableJobsListMobile } from './available-jobs-list-mobile'; + +interface AvailableJobsTableMobileProps { + handleOpenMobileFilterDrawer: () => void; +} + +export function AvailableJobsTableMobile({ + handleOpenMobileFilterDrawer, +}: Readonly) { + const { t } = useTranslation(); + const { setSearchEscrowAddress } = useJobsFilterStore(); + + return ( + <> + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/index.ts new file mode 100644 index 0000000000..04265ea0ed --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/mobile/index.ts @@ -0,0 +1,2 @@ +export * from './available-jobs-reward-amount-sort-mobile'; +export * from './available-jobs-table-mobile'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/index.ts new file mode 100644 index 0000000000..b0cfd707d8 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './use-get-available-jobs-columns'; +export * from './use-get-available-jobs-data'; +export * from './use-assign-job'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/use-assign-job.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-assign-job.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/services/use-assign-job.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-assign-job.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-pagination.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-pagination.tsx new file mode 100644 index 0000000000..c44dab7a5d --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-pagination.tsx @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react'; +import type { PageSize } from '@/shared/types/entity.type'; +import { type JobsFilterStoreProps } from '../../hooks'; + +interface PaginationProps { + setPageParams: (pageIndex: number, pageSize: PageSize) => void; + filterParams: JobsFilterStoreProps['filterParams']; +} + +export const useAvailableJobsPagination = ({ + setPageParams, + filterParams, +}: PaginationProps) => { + const [paginationState, setPaginationState] = useState({ + pageIndex: 0, + pageSize: 5, + }); + + useEffect(() => { + if (!(paginationState.pageSize === 5 || paginationState.pageSize === 10)) + return; + setPageParams(paginationState.pageIndex, paginationState.pageSize); + }, [paginationState, setPageParams]); + + useEffect(() => { + setPaginationState({ + pageIndex: filterParams.page, + pageSize: filterParams.page_size, + }); + }, [filterParams.page, filterParams.page_size]); + + return { paginationState, setPaginationState }; +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx new file mode 100644 index 0000000000..3bba5a3acb --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx @@ -0,0 +1,155 @@ +/* eslint-disable camelcase -- ... */ +import type { MRT_ColumnDef } from 'material-react-table'; +import { t } from 'i18next'; +import { Grid } from '@mui/material'; +import { useMemo } from 'react'; +import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; +import { Chip } from '@/shared/components/ui/chip'; +import { TableButton } from '@/shared/components/ui/table-button'; +import { TableHeaderCell } from '@/shared/components/ui/table/table-header-cell'; +import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; +import { useJobsNotifications } from '../../hooks'; +import { EvmAddress, RewardAmount } from '../../components'; +import { + AvailableJobsNetworkFilter, + AvailableJobsRewardAmountSort, + AvailableJobsJobTypeFilter, +} from '../components'; +import { type AvailableJob } from './use-get-available-jobs-data'; +import { useAssignJobMutation } from './use-assign-job'; + +const COL_SIZE = 100; +const COL_SIZE_LG = 200; + +export const useGetAvailableJobsColumns = ( + chainIdsEnabled: number[] +): MRT_ColumnDef[] => { + return useMemo( + () => [ + { + accessorKey: 'job_description', + header: t('worker.jobs.jobDescription'), + size: COL_SIZE, + enableSorting: false, + }, + { + accessorKey: 'escrow_address', + header: t('worker.jobs.escrowAddress'), + size: COL_SIZE, + enableSorting: false, + Cell: (props) => { + return ; + }, + }, + { + accessorKey: 'chain_id', + header: t('worker.jobs.network'), + size: COL_SIZE, + enableSorting: false, + Cell: (props) => { + return getNetworkName(props.row.original.chain_id); + }, + muiTableHeadCellProps: () => ({ + component: (props) => { + return ( + + } + /> + ); + }, + }), + }, + { + accessorKey: 'reward_amount', + header: t('worker.jobs.rewardAmount'), + size: COL_SIZE, + enableSorting: false, + Cell: (props) => { + const { reward_amount, reward_token } = props.row.original; + return ( + + ); + }, + muiTableHeadCellProps: () => ({ + component: (props) => ( + } + /> + ), + }), + }, + { + accessorKey: 'job_type', + header: t('worker.jobs.jobType'), + size: COL_SIZE_LG, + enableSorting: false, + Cell: ({ row }) => { + const label = t(`jobTypeLabels.${row.original.job_type as JobType}`); + return ; + }, + muiTableHeadCellProps: () => ({ + component: (props) => { + return ( + } + /> + ); + }, + }), + }, + { + accessorKey: 'escrow_address', + id: 'selectJobAction', + header: '', + size: COL_SIZE, + enableSorting: false, + Cell: (props) => { + const { escrow_address, chain_id } = props.row.original; + const { onJobAssignmentError, onJobAssignmentSuccess } = + useJobsNotifications(); + const { mutate: assignJobMutation, isPending } = useAssignJobMutation( + { + onSuccess: onJobAssignmentSuccess, + onError: onJobAssignmentError, + }, + [`assignJob-${escrow_address}`] + ); + + return ( + + { + assignJobMutation({ escrow_address, chain_id }); + }} + sx={{ + width: '94px', + }} + > + {t('worker.jobs.selectJob')} + + + ); + }, + }, + ], + [chainIdsEnabled] + ); +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/available-jobs-data.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-data.ts similarity index 91% rename from packages/apps/human-app/frontend/src/modules/worker/services/available-jobs-data.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-data.ts index f0dfff4f6b..212a919a29 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/services/available-jobs-data.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-data.ts @@ -5,9 +5,8 @@ import { useParams } from 'react-router-dom'; import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; import { stringifyUrlQueryObject } from '@/shared/helpers/transfomers'; -import type { JobsFilterStoreProps } from '@/modules/worker/hooks/use-jobs-filter-store'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; import { createPaginationSchema } from '@/shared/helpers/pagination'; +import { type JobsFilterStoreProps, useJobsFilterStore } from '../../hooks'; const availableJobSchema = z.object({ escrow_address: z.string(), @@ -59,7 +58,7 @@ export function useGetAvailableJobsData() { }); } -export function useInfiniteAvailableJobsQuery() { +export function useInifiniteGetAvailableJobsData() { const { filterParams } = useJobsFilterStore(); const { address: oracleAddress } = useParams<{ address: string }>(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/index.ts new file mode 100644 index 0000000000..b4efea1d49 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/index.ts @@ -0,0 +1,2 @@ +export * from './available-jobs-view'; +export * from './available-jobs-drawer-mobile-view'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/escrow-address-search-form.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/escrow-address-search-form.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/escrow-address-search-form.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/escrow-address-search-form.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/evm-address.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/evm-address.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/evm-address.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/evm-address.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts new file mode 100644 index 0000000000..0fb14ebde7 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts @@ -0,0 +1,8 @@ +export * from './evm-address'; +export * from './jobs-tab-panel'; +export * from './my-jobs-data'; +export * from './my-jobs-table-actions'; +export * from './escrow-address-search-form'; +export * from './reject-button'; +export * from './reward-amount'; +export * from './sorting'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/jobs-tab-panel.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/jobs-tab-panel.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/jobs-tab-panel.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/jobs-tab-panel.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/my-jobs-data.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-data.ts similarity index 86% rename from packages/apps/human-app/frontend/src/modules/worker/services/my-jobs-data.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-data.ts index 3d72f81054..aac364251f 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/services/my-jobs-data.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-data.ts @@ -6,19 +6,8 @@ import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; import { stringifyUrlQueryObject } from '@/shared/helpers/transfomers'; import { createPaginationSchema } from '@/shared/helpers/pagination'; -import type { MyJobsFilterStoreProps } from '@/modules/worker/hooks/use-my-jobs-filter-store'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; - -export enum MyJobStatus { - ACTIVE = 'ACTIVE', - CANCELED = 'CANCELED', - COMPLETED = 'COMPLETED', - VALIDATION = 'VALIDATION', - EXPIRED = 'EXPIRED', - REJECTED = 'REJECTED', -} - -export const UNKNOWN_JOB_STATUS = 'UNKNOWN'; +import { MyJobStatus, UNKNOWN_JOB_STATUS } from '../types'; +import { type MyJobsFilterStoreProps, useMyJobsFilterStore } from '../hooks'; const myJobSchema = z.object({ assignment_id: z.string(), diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs-table-actions.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-table-actions.tsx similarity index 78% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs-table-actions.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-table-actions.tsx index 1470a87cb8..76c4f49466 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs-table-actions.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-table-actions.tsx @@ -1,19 +1,19 @@ /* eslint-disable camelcase -- ...*/ import { Link, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { useRejectTaskMutation } from '@/modules/worker/services/reject-task'; import { TableButton } from '@/shared/components/ui/table-button'; -import { RejectButton } from '@/modules/worker/components/jobs/reject-button'; -import { - MyJobStatus, - type MyJob, -} from '@/modules/worker/services/my-jobs-data'; +import { useRejectTaskMutation } from '../my-jobs/hooks'; +import { MyJobStatus } from '../types'; +import { type MyJob } from './my-jobs-data'; +import { RejectButton } from './reject-button'; interface MyJobsTableRejectActionProps { job: MyJob; } -export function MyJobsTableActions({ job }: MyJobsTableRejectActionProps) { +export function MyJobsTableActions({ + job, +}: Readonly) { const { t } = useTranslation(); const { mutate: rejectTaskMutation, isPending: isRejectPending } = useRejectTaskMutation(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/reject-button.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/reject-button.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/reject-button.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/reject-button.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/reward-amount.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/reward-amount.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/reward-amount.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/reward-amount.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/sorting.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/sorting.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/sorting.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/components/sorting.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts new file mode 100644 index 0000000000..eceb07072a --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts @@ -0,0 +1,6 @@ +export * from './use-get-all-networks'; +export * from './use-jobs-notifications'; +export * from './use-job-types-oracles-table'; +export * from './use-jobs-filter-store'; +export * from './use-my-jobs-filter-store'; +export * from './use-get-ui-config'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-all-networks.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-get-all-networks.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/hooks/use-get-all-networks.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-get-all-networks.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/get-ui-config.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-get-ui-config.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/services/get-ui-config.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-get-ui-config.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-job-types-oracles-table.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-job-types-oracles-table.tsx similarity index 78% rename from packages/apps/human-app/frontend/src/modules/worker/hooks/use-job-types-oracles-table.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-job-types-oracles-table.tsx index 229a0472eb..60943b3ecf 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-job-types-oracles-table.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-job-types-oracles-table.tsx @@ -6,8 +6,8 @@ export interface JobsTypesOraclesFilterStore { selectJobType: (jobType: string[]) => void; } -export const useJobsTypesOraclesFilter = create( - (set) => ({ +export const useJobsTypesOraclesFilterStore = + create((set) => ({ selected_job_types: [], selectJobType: (jobTypes: string[]) => { set((state) => ({ @@ -15,5 +15,4 @@ export const useJobsTypesOraclesFilter = create( selected_job_types: jobTypes, })); }, - }) -); + })); diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-jobs-filter-store.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-filter-store.tsx similarity index 90% rename from packages/apps/human-app/frontend/src/modules/worker/hooks/use-jobs-filter-store.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-filter-store.tsx index 68ce87e540..fa739bd147 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-jobs-filter-store.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-filter-store.tsx @@ -1,17 +1,12 @@ /* eslint-disable camelcase -- api params*/ import { create } from 'zustand'; import type { PageSize } from '@/shared/types/entity.type'; -import { MyJobStatus } from '@/modules/worker/services/my-jobs-data'; +import { MyJobStatus, type SortDirection, type SortField } from '../types'; export interface JobsFilterStoreProps { filterParams: { - sort?: 'asc' | 'desc'; - sort_field?: - | 'chain_id' - | 'job_type' - | 'reward_amount' - | 'created_at' - | 'escrow_address'; + sort?: SortDirection; + sort_field?: SortField; network?: 'MATIC' | 'POLYGON'; // TODO add allowed job types job_type?: string; diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-jobs-notifications.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-notifications.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/hooks/use-jobs-notifications.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-jobs-notifications.tsx diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-my-jobs-filter-store.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-my-jobs-filter-store.tsx similarity index 88% rename from packages/apps/human-app/frontend/src/modules/worker/hooks/use-my-jobs-filter-store.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-my-jobs-filter-store.tsx index 074439b923..4e2063ded5 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-my-jobs-filter-store.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-my-jobs-filter-store.tsx @@ -1,17 +1,12 @@ /* eslint-disable camelcase -- api params*/ import { create } from 'zustand'; import type { PageSize } from '@/shared/types/entity.type'; -import { type MyJobStatus } from '@/modules/worker/services/my-jobs-data'; +import { SortDirection, SortField, type MyJobStatus } from '../types'; export interface MyJobsFilterStoreProps { filterParams: { - sort?: 'asc' | 'desc'; - sort_field?: - | 'chain_id' - | 'job_type' - | 'reward_amount' - | 'expires_at' - | 'created_at'; + sort?: SortDirection; + sort_field?: SortField; job_type?: string; status?: MyJobStatus; escrow_address?: string; @@ -34,8 +29,8 @@ const initialFiltersState = { escrow_address: '', page: 0, page_size: 5, - sort_field: 'created_at', - sort: 'desc', + sort_field: SortField.CREATED_AT, + sort: SortDirection.DESC, } as const; export const useMyJobsFilterStore = create((set) => ({ diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/index.ts new file mode 100644 index 0000000000..846093be12 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/index.ts @@ -0,0 +1 @@ +export * from './jobs.page'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/views/jobs/jobs.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx similarity index 68% rename from packages/apps/human-app/frontend/src/modules/worker/views/jobs/jobs.page.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx index 31aa77491f..c4c0abfb55 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/views/jobs/jobs.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx @@ -1,24 +1,24 @@ /* eslint-disable camelcase */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Grid, Paper, Stack, Tab, Tabs, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { TableQueryContextProvider } from '@/shared/components/ui/table/table-query-context'; import { Modal } from '@/shared/components/ui/modal/modal'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; -import { MyJobsTableMobile } from '@/modules/worker/components/jobs/my-jobs/mobile/my-jobs-table-mobile'; -import { AvailableJobsTable } from '@/modules/worker/components/jobs/available-jobs/desktop/available-jobs-table'; -import { MyJobsDrawerMobile } from '@/modules/worker/components/jobs/my-jobs/mobile/my-jobs-drawer-mobile'; -import { AvailableJobsDrawerMobile } from '@/modules/worker/components/jobs/available-jobs/mobile/available-jobs-drawer-mobile'; -import { useGetOracles } from '@/modules/worker/jobs-discovery'; -import { useGetUiConfig } from '@/modules/worker/services/get-ui-config'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { useGetOraclesNotifications } from '@/modules/worker/hooks/use-get-oracles-notifications'; import { NoRecords } from '@/shared/components/ui/no-records'; -import { AvailableJobsTableMobile } from '@/modules/worker/components/jobs/available-jobs/mobile/available-jobs-table-mobile'; -import { TabPanel } from '@/modules/worker/components/jobs/jobs-tab-panel'; -import { MyJobsTable } from '@/modules/worker/components/jobs/my-jobs/desktop/my-jobs-table'; import { PageCardLoader } from '@/shared/components/ui/page-card'; +import { useGetOracles } from '../hooks'; +import { useGetOraclesNotifications } from '../hooks/use-get-oracles-notifications'; +import { useGetUiConfig } from './hooks'; +import { TabPanel } from './components'; +import { MyJobsDrawerMobileView } from './my-jobs/components/mobile'; +import { + AvailableJobsView, + AvailableJobsDrawerMobileView, +} from './available-jobs'; +import { MyJobsView } from './my-jobs/my-jobs-view'; function generateTabA11yProps(index: number) { return { @@ -54,7 +54,6 @@ export function JobsPage() { const [isMobileFilterDrawerOpen, setIsMobileFilterDrawerOpen] = useState(false); const { onError } = useGetOraclesNotifications(); - const onErrorRef = useRef(onError); const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { setActiveTab(newValue); @@ -66,11 +65,15 @@ export function JobsPage() { } }; + const handleOpenMobileFilterDrawer = () => { + setIsMobileFilterDrawerOpen(true); + }; + useEffect(() => { if (error) { - onErrorRef.current(error); + onError(error); } - }, [error]); + }, [error, onError]); const oracleName = data?.find( ({ address }) => address === oracle_address @@ -84,13 +87,13 @@ export function JobsPage() { <> {selectedTab === 'availableJobs' && uiConfigData && ( - )} {selectedTab === 'myJobs' && uiConfigData && ( - @@ -152,38 +155,24 @@ export function JobsPage() { {isError ? ( ) : ( - <> - {isMobile ? ( - - ) : ( - - )} - + )} {isError ? ( ) : ( - <> - {isMobile ? ( - - ) : ( - - )} - + )} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/index.ts new file mode 100644 index 0000000000..677305df30 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/index.ts @@ -0,0 +1 @@ +export * from './my-jobs-table'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-expires-at-sort.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-expires-at-sort.tsx similarity index 70% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-expires-at-sort.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-expires-at-sort.tsx index 7ec9cb2c83..dc48b16472 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-expires-at-sort.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-expires-at-sort.tsx @@ -1,24 +1,23 @@ /* eslint-disable camelcase --- ... */ import { t } from 'i18next'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { Sorting } from '@/shared/components/ui/table/table-header-menu.tsx/sorting'; +import { useMyJobsFilterStore } from '../../../hooks'; +import { SortDirection, SortField } from '../../../types'; export function MyJobsExpiresAtSort() { - const { setFilterParams, filterParams } = useMyJobsFilterStore(); + const { setFilterParams } = useMyJobsFilterStore(); const sortAscExpiresAt = () => { setFilterParams({ - ...filterParams, - sort_field: 'expires_at', - sort: 'asc', + sort_field: SortField.EXPIRES_AT, + sort: SortDirection.ASC, }); }; const sortDescExpiresAt = () => { setFilterParams({ - ...filterParams, - sort_field: 'expires_at', - sort: 'desc', + sort_field: SortField.EXPIRES_AT, + sort: SortDirection.DESC, }); }; @@ -26,7 +25,6 @@ export function MyJobsExpiresAtSort() { { setFilterParams({ - ...filterParams, sort_field: undefined, sort: undefined, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-job-type-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-job-type-filter.tsx similarity index 76% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-job-type-filter.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-job-type-filter.tsx index fd5b5293c1..1992596ac0 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-job-type-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-job-type-filter.tsx @@ -1,29 +1,33 @@ /* eslint-disable camelcase --- ... */ import { useTranslation } from 'react-i18next'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; +import { useMemo } from 'react'; import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; import { JOB_TYPES } from '@/shared/consts'; +import { useMyJobsFilterStore } from '../../../hooks'; export function MyJobsJobTypeFilter() { const { t } = useTranslation(); const { setFilterParams, filterParams } = useMyJobsFilterStore(); + const filteringOptions = useMemo( + () => + JOB_TYPES.map((jobType) => ({ + name: t(`jobTypeLabels.${jobType}`), + option: jobType, + })), + [t] + ); return ( { setFilterParams({ - ...filterParams, job_type: undefined, }); }} - filteringOptions={JOB_TYPES.map((jobType) => ({ - name: t(`jobTypeLabels.${jobType}`), - option: jobType, - }))} + filteringOptions={filteringOptions} isChecked={(option) => option === filterParams.job_type} setFiltering={(jobType) => { setFilterParams({ - ...filterParams, job_type: jobType, }); }} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-network-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-network-filter.tsx similarity index 74% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-network-filter.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-network-filter.tsx index 82b4690514..71e099b10f 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-network-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-network-filter.tsx @@ -1,7 +1,6 @@ /* eslint-disable camelcase --- ... */ -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; -import { useGetAllNetworks } from '@/modules/worker/hooks/use-get-all-networks'; import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { useMyJobsFilterStore, useGetAllNetworks } from '../../../hooks'; interface MyJobsNetworkFilterProps { chainIdsEnabled: number[]; @@ -9,7 +8,7 @@ interface MyJobsNetworkFilterProps { export function MyJobsNetworkFilter({ chainIdsEnabled, -}: MyJobsNetworkFilterProps) { +}: Readonly) { const { setFilterParams, filterParams } = useMyJobsFilterStore(); const { allNetworks } = useGetAllNetworks(chainIdsEnabled); @@ -17,7 +16,6 @@ export function MyJobsNetworkFilter({ { setFilterParams({ - ...filterParams, chain_id: undefined, }); }} @@ -25,7 +23,6 @@ export function MyJobsNetworkFilter({ isChecked={(option) => option === filterParams.chain_id} setFiltering={(chainId) => { setFilterParams({ - ...filterParams, chain_id: chainId, }); }} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-reward-amount-sort.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-reward-amount-sort.tsx similarity index 70% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-reward-amount-sort.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-reward-amount-sort.tsx index 397f697ffe..293d32abae 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-reward-amount-sort.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-reward-amount-sort.tsx @@ -1,24 +1,23 @@ /* eslint-disable camelcase --- ... */ import { t } from 'i18next'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { Sorting } from '@/shared/components/ui/table/table-header-menu.tsx/sorting'; +import { useMyJobsFilterStore } from '../../../hooks'; +import { SortDirection, SortField } from '../../../types'; export function MyJobsRewardAmountSort() { - const { setFilterParams, filterParams } = useMyJobsFilterStore(); + const { setFilterParams } = useMyJobsFilterStore(); const sortAscRewardAmount = () => { setFilterParams({ - ...filterParams, - sort_field: 'reward_amount', - sort: 'asc', + sort_field: SortField.REWARD_AMOUNT, + sort: SortDirection.ASC, }); }; const sortDescRewardAmount = () => { setFilterParams({ - ...filterParams, - sort_field: 'reward_amount', - sort: 'desc', + sort_field: SortField.REWARD_AMOUNT, + sort: SortDirection.DESC, }); }; @@ -26,7 +25,6 @@ export function MyJobsRewardAmountSort() { { setFilterParams({ - ...filterParams, sort_field: undefined, sort: undefined, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-status-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-status-filter.tsx similarity index 76% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-status-filter.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-status-filter.tsx index 363e0a8299..285f1e334e 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-status-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-status-filter.tsx @@ -1,7 +1,7 @@ import capitalize from 'lodash/capitalize'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; -import { MyJobStatus } from '@/modules/worker/services/my-jobs-data'; +import { useMyJobsFilterStore } from '../../../hooks'; +import { MyJobStatus } from '../../../types'; export function MyJobsStatusFilter() { const { setFilterParams, filterParams } = useMyJobsFilterStore(); @@ -10,7 +10,6 @@ export function MyJobsStatusFilter() { { setFilterParams({ - ...filterParams, status: undefined, }); }} @@ -21,7 +20,6 @@ export function MyJobsStatusFilter() { isChecked={(status) => status === filterParams.status} setFiltering={(status) => { setFilterParams({ - ...filterParams, status, }); }} diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-table.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-table.tsx similarity index 87% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-table.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-table.tsx index 9b66f0c267..923b27e1b4 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/desktop/my-jobs-table.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-table.tsx @@ -10,29 +10,29 @@ import { } from 'material-react-table'; import RefreshIcon from '@mui/icons-material/Refresh'; import { TableHeaderCell } from '@/shared/components/ui/table/table-header-cell'; -import { - useGetMyJobsData, - type MyJob, -} from '@/modules/worker/services/my-jobs-data'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; -import { RewardAmount } from '@/modules/worker/components/jobs/reward-amount'; import { Button } from '@/shared/components/ui/button'; import { Chip } from '@/shared/components/ui/chip'; -import { EvmAddress } from '@/modules/worker/components/jobs/evm-address'; -import { MyJobsJobTypeFilter } from '@/modules/worker/components/jobs/my-jobs/desktop/my-jobs-job-type-filter'; -import { MyJobsRewardAmountSort } from '@/modules/worker/components/jobs/my-jobs/desktop/my-jobs-reward-amount-sort'; -import { MyJobsStatusFilter } from '@/modules/worker/components/jobs/my-jobs/desktop/my-jobs-status-filter'; -import { MyJobsExpiresAtSort } from '@/modules/worker/components/jobs/my-jobs/desktop/my-jobs-expires-at-sort'; -import { MyJobsNetworkFilter } from '@/modules/worker/components/jobs/my-jobs/desktop/my-jobs-network-filter'; import { useColorMode } from '@/shared/contexts/color-mode'; import { createTableDarkMode } from '@/shared/styles/create-table-dark-mode'; import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; -import { EscrowAddressSearchForm } from '@/modules/worker/components/jobs/escrow-address-search-form'; -import { useRefreshTasksMutation } from '@/modules/worker/services/refresh-tasks'; -import { StatusChip } from '@/modules/worker/components/jobs/status-chip'; import { formatDate } from '@/shared/helpers/date'; -import { MyJobsTableActions } from '../../my-jobs-table-actions'; +import { StatusChip } from '../status-chip'; +import { + type MyJob, + EvmAddress, + RewardAmount, + MyJobsTableActions, + useGetMyJobsData, + EscrowAddressSearchForm, +} from '../../../components'; +import { useMyJobsFilterStore } from '../../../hooks'; +import { useRefreshTasksMutation } from '../../hooks'; +import { MyJobsExpiresAtSort } from './my-jobs-expires-at-sort'; +import { MyJobsJobTypeFilter } from './my-jobs-job-type-filter'; +import { MyJobsNetworkFilter } from './my-jobs-network-filter'; +import { MyJobsRewardAmountSort } from './my-jobs-reward-amount-sort'; +import { MyJobsStatusFilter } from './my-jobs-status-filter'; interface MyJobsTableProps { chainIdsEnabled: number[]; @@ -213,7 +213,7 @@ const getColumnsDefinition = ({ }, ]; -export function MyJobsTable({ chainIdsEnabled }: MyJobsTableProps) { +export function MyJobsTable({ chainIdsEnabled }: Readonly) { const { colorPalette, isDarkMode } = useColorMode(); const { setSearchEscrowAddress, diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/index.ts new file mode 100644 index 0000000000..ec2a70a7f5 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/index.ts @@ -0,0 +1,2 @@ +export * from './my-jobs-drawer-mobile'; +export * from './my-jobs-list-mobile'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-drawer-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-drawer-mobile.tsx similarity index 87% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-drawer-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-drawer-mobile.tsx index 9355052137..35a5fd25e3 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-drawer-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-drawer-mobile.tsx @@ -7,21 +7,21 @@ import CloseIcon from '@mui/icons-material/Close'; import type { Dispatch, SetStateAction } from 'react'; import { HumanLogoIcon } from '@/shared/components/ui/icons'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { MyJobsRewardAmountSortMobile } from '@/modules/worker/components/jobs/my-jobs/mobile/my-jobs-reward-amount-sort-mobile'; -import { MyJobsExpiresAtSortMobile } from '@/modules/worker/components/jobs/my-jobs/mobile/my-jobs-expires-at-sort-mobile'; import { useHandleMainNavIconClick } from '@/shared/hooks/use-handle-main-nav-icon-click'; import { MyJobsNetworkFilterMobile } from './my-jobs-network-filter-mobile'; import { MyJobsJobTypeFilterMobile } from './my-jobs-job-type-filter-mobile'; import { MyJobsStatusFilterMobile } from './my-jobs-status-filter-mobile'; +import { MyJobsExpiresAtSortMobile } from './my-jobs-expires-at-sort-mobile'; +import { MyJobsRewardAmountSortMobile } from './my-jobs-reward-amount-sort-mobile'; interface DrawerMobileProps { setIsMobileFilterDrawerOpen: Dispatch>; chainIdsEnabled: number[]; } -export function MyJobsDrawerMobile({ +export function MyJobsDrawerMobileView({ setIsMobileFilterDrawerOpen, chainIdsEnabled, -}: DrawerMobileProps) { +}: Readonly) { const handleMainNavIconClick = useHandleMainNavIconClick(); const { colorPalette } = useColorMode(); const { t } = useTranslation(); @@ -108,11 +108,7 @@ export function MyJobsDrawerMobile({ {t('worker.jobs.network')} - + @@ -125,11 +121,7 @@ export function MyJobsDrawerMobile({ {t('worker.jobs.jobType')} - + } fromHighestSelected={ - filterParams.sort_field === 'expires_at' && filterParams.sort === 'desc' + filterParams.sort_field === SortField.EXPIRES_AT && + filterParams.sort === SortDirection.DESC } sortFromHighest={() => { setFilterParams({ - ...filterParams, - sort: 'desc', - sort_field: 'expires_at', + sort: SortDirection.DESC, + sort_field: SortField.EXPIRES_AT, }); }} fromLowestSelected={ - filterParams.sort_field === 'expires_at' && filterParams.sort === 'asc' + filterParams.sort_field === SortField.EXPIRES_AT && + filterParams.sort === SortDirection.ASC } sortFromLowest={() => { setFilterParams({ - ...filterParams, - sort: 'asc', - sort_field: 'expires_at', + sort: SortDirection.ASC, + sort_field: SortField.EXPIRES_AT, }); }} clear={() => { setFilterParams({ - ...filterParams, sort: undefined, sort_field: undefined, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-job-type-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-job-type-filter-mobile.tsx similarity index 86% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-job-type-filter-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-job-type-filter-mobile.tsx index 3ed59dc791..6c570f07f5 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-job-type-filter-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-job-type-filter-mobile.tsx @@ -1,8 +1,8 @@ /* eslint-disable camelcase --- ... */ import { useTranslation } from 'react-i18next'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; import { JOB_TYPES } from '@/shared/consts'; +import { useMyJobsFilterStore } from '../../../hooks'; export function MyJobsJobTypeFilterMobile() { const { t } = useTranslation(); @@ -12,7 +12,6 @@ export function MyJobsJobTypeFilterMobile() { { setFilterParams({ - ...filterParams, job_type: undefined, page: 0, }); @@ -25,7 +24,6 @@ export function MyJobsJobTypeFilterMobile() { isMobile={false} setFiltering={(jobType) => { setFilterParams({ - ...filterParams, job_type: jobType, page: 0, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-table-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx similarity index 83% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-table-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx index 02cb08a5e9..ece4d8d966 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-table-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx @@ -1,40 +1,41 @@ /* eslint-disable camelcase -- ... */ import { Grid, List, Paper, Stack, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { useEffect, type Dispatch, type SetStateAction } from 'react'; import { useParams } from 'react-router-dom'; import { Button } from '@/shared/components/ui/button'; import { FiltersButtonIcon, RefreshIcon } from '@/shared/components/ui/icons'; import { Loader } from '@/shared/components/ui/loader'; import { Alert } from '@/shared/components/ui/alert'; import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; -import { useJobsFilterStore } from '@/modules/worker/hooks/use-jobs-filter-store'; -import type { MyJob } from '@/modules/worker/services/my-jobs-data'; -import { useInfiniteGetMyJobsData } from '@/modules/worker/services/my-jobs-data'; import { getErrorMessageForError } from '@/shared/errors'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { ListItem } from '@/shared/components/ui/list-item'; -import { EvmAddress } from '@/modules/worker/components/jobs/evm-address'; -import { RewardAmount } from '@/modules/worker/components/jobs/reward-amount'; import { useColorMode } from '@/shared/contexts/color-mode'; import { Chip } from '@/shared/components/ui/chip'; import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; -import { EscrowAddressSearchForm } from '@/modules/worker/components/jobs/escrow-address-search-form'; import { colorPalette as lightModeColorPalette } from '@/shared/styles/color-palette'; -import { useRefreshTasksMutation } from '@/modules/worker/services/refresh-tasks'; -import { getChipStatusColor } from '@/modules/worker/utils/get-chip-status-color'; import { formatDate } from '@/shared/helpers/date'; -import { MyJobsTableActions } from '../../my-jobs-table-actions'; +import { useCombinePages } from '@/shared/hooks'; +import { + useInfiniteGetMyJobsData, + type MyJob, + EscrowAddressSearchForm, + EvmAddress, + RewardAmount, + MyJobsTableActions, +} from '../../../components'; +import { useMyJobsFilterStore, useJobsFilterStore } from '../../../hooks'; +import { useRefreshTasksMutation } from '../../hooks'; +import { getChipStatusColor } from '../../utils'; -interface MyJobsTableMobileProps { +interface MyJobsListMobileProps { setIsMobileFilterDrawerOpen: Dispatch>; } -export function MyJobsTableMobile({ +export function MyJobsListMobile({ setIsMobileFilterDrawerOpen, -}: MyJobsTableMobileProps) { +}: Readonly) { const { colorPalette } = useColorMode(); - const [allPages, setAllPages] = useState([]); const { filterParams, setPageParams, resetFilterParams } = useMyJobsFilterStore(); @@ -53,15 +54,7 @@ export function MyJobsTableMobile({ const { setSearchEscrowAddress } = useJobsFilterStore(); const { address: oracle_address } = useParams<{ address: string }>(); - useEffect(() => { - if (!tableData) return; - const pagesFromRes = tableData.pages.flatMap((pages) => pages.results); - if (filterParams.page === 0) { - setAllPages(pagesFromRes); - } else { - setAllPages((state) => [...state, ...pagesFromRes]); - } - }, [tableData, filterParams.page]); + const allPages = useCombinePages(tableData, filterParams.page); useEffect(() => { return () => { diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-network-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-network-filter-mobile.tsx similarity index 75% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-network-filter-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-network-filter-mobile.tsx index c1bb8ac908..9bfbc7e5ba 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-network-filter-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-network-filter-mobile.tsx @@ -1,7 +1,6 @@ /* eslint-disable camelcase --- ... */ -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; -import { useGetAllNetworks } from '@/modules/worker/hooks/use-get-all-networks'; +import { useMyJobsFilterStore, useGetAllNetworks } from '../../../hooks'; interface MyJobsNetworkFilterMobileProps { chainIdsEnabled: number[]; @@ -9,7 +8,7 @@ interface MyJobsNetworkFilterMobileProps { export function MyJobsNetworkFilterMobile({ chainIdsEnabled, -}: MyJobsNetworkFilterMobileProps) { +}: Readonly) { const { setFilterParams, filterParams } = useMyJobsFilterStore(); const { allNetworks } = useGetAllNetworks(chainIdsEnabled); @@ -17,7 +16,6 @@ export function MyJobsNetworkFilterMobile({ { setFilterParams({ - ...filterParams, chain_id: undefined, page: 0, }); @@ -27,7 +25,6 @@ export function MyJobsNetworkFilterMobile({ isMobile={false} setFiltering={(chainId) => { setFilterParams({ - ...filterParams, chain_id: chainId, page: 0, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-reward-amount-sort-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-reward-amount-sort-mobile.tsx similarity index 60% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-reward-amount-sort-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-reward-amount-sort-mobile.tsx index 818276a65a..b40381ae3c 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-reward-amount-sort-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-reward-amount-sort-mobile.tsx @@ -2,12 +2,15 @@ import Typography from '@mui/material/Typography'; import { t } from 'i18next'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; -import { Sorting } from '@/modules/worker/components/jobs/sorting'; +import { Sorting } from '../../../components'; +import { useMyJobsFilterStore } from '../../../hooks'; +import { SortDirection, SortField } from '../../../types'; export function MyJobsRewardAmountSortMobile() { const { setFilterParams, filterParams } = useMyJobsFilterStore(); const { colorPalette } = useColorMode(); + const isRewardAmountSortSelected = + filterParams.sort_field === SortField.REWARD_AMOUNT; return ( } fromHighestSelected={ - filterParams.sort_field === 'reward_amount' && - filterParams.sort === 'desc' + isRewardAmountSortSelected && filterParams.sort === SortDirection.DESC } sortFromHighest={() => { setFilterParams({ - ...filterParams, - sort: 'desc', - sort_field: 'reward_amount', + sort: SortDirection.DESC, + sort_field: SortField.REWARD_AMOUNT, }); }} fromLowestSelected={ - filterParams.sort_field === 'reward_amount' && - filterParams.sort === 'asc' + isRewardAmountSortSelected && filterParams.sort === SortDirection.ASC } sortFromLowest={() => { setFilterParams({ - ...filterParams, - sort: 'asc', - sort_field: 'reward_amount', + sort: SortDirection.ASC, + sort_field: SortField.REWARD_AMOUNT, }); }} clear={() => { setFilterParams({ - ...filterParams, sort: undefined, sort_field: undefined, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-status-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-status-filter-mobile.tsx similarity index 77% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-status-filter-mobile.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-status-filter-mobile.tsx index 3f82a4b3a3..ce4f90c46a 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/my-jobs/mobile/my-jobs-status-filter-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-status-filter-mobile.tsx @@ -1,7 +1,7 @@ import capitalize from 'lodash/capitalize'; -import { useMyJobsFilterStore } from '@/modules/worker/hooks/use-my-jobs-filter-store'; import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; -import { MyJobStatus } from '@/modules/worker/services/my-jobs-data'; +import { useMyJobsFilterStore } from '../../../hooks'; +import { MyJobStatus } from '../../../types'; export function MyJobsStatusFilterMobile() { const { setFilterParams, filterParams } = useMyJobsFilterStore(); @@ -10,7 +10,6 @@ export function MyJobsStatusFilterMobile() { { setFilterParams({ - ...filterParams, status: undefined, page: 0, }); @@ -22,7 +21,6 @@ export function MyJobsStatusFilterMobile() { isChecked={(status) => status === filterParams.status} setFiltering={(status) => { setFilterParams({ - ...filterParams, status, page: 0, }); diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/status-chip.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/status-chip.tsx similarity index 76% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/status-chip.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/status-chip.tsx index 5d33c3ecc0..ebc6e7db65 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/status-chip.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/status-chip.tsx @@ -1,11 +1,11 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { type MyJob } from '@/modules/worker/services/my-jobs-data'; import { colorPalette as lightModeColorPalette } from '@/shared/styles/color-palette'; -import { getChipStatusColor } from '@/modules/worker/utils/get-chip-status-color'; import { useColorMode } from '@/shared/contexts/color-mode'; +import { type MyJob } from '../../components'; +import { getChipStatusColor } from '../utils'; -export function StatusChip({ status }: { status: MyJob['status'] }) { +export function StatusChip({ status }: Readonly<{ status: MyJob['status'] }>) { const { colorPalette } = useColorMode(); return ( diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/index.ts new file mode 100644 index 0000000000..d4e2db7bde --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './refresh-tasks'; +export * from './reject-task'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/refresh-tasks.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/refresh-tasks.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/services/refresh-tasks.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/refresh-tasks.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/reject-task.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/reject-task.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/services/reject-task.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/reject-task.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/my-jobs-view.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/my-jobs-view.tsx new file mode 100644 index 0000000000..099413099b --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/my-jobs-view.tsx @@ -0,0 +1,22 @@ +import { type Dispatch, type SetStateAction } from 'react'; +import { useIsMobile } from '@/shared/hooks'; +import { MyJobsTable } from './components/desktop'; +import { MyJobsListMobile } from './components/mobile'; + +export function MyJobsView({ + setIsMobileFilterDrawerOpen, + chainIdsEnabled, +}: { + setIsMobileFilterDrawerOpen: Dispatch>; + chainIdsEnabled: number[]; +}) { + const isMobile = useIsMobile(); + + return isMobile ? ( + + ) : ( + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/utils/get-chip-status-color.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/get-chip-status-color.ts similarity index 82% rename from packages/apps/human-app/frontend/src/modules/worker/utils/get-chip-status-color.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/get-chip-status-color.ts index 435e51ce1e..c791586ee8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/utils/get-chip-status-color.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/get-chip-status-color.ts @@ -1,8 +1,5 @@ -import { - MyJobStatus, - type UNKNOWN_JOB_STATUS, -} from '@/modules/worker/services/my-jobs-data'; import { type ColorPalette } from '@/shared/styles/color-palette'; +import { MyJobStatus, type UNKNOWN_JOB_STATUS } from '../../types'; export function getChipStatusColor( status: MyJobStatus | typeof UNKNOWN_JOB_STATUS, diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/index.ts new file mode 100644 index 0000000000..bfddc6caac --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/utils/index.ts @@ -0,0 +1 @@ +export * from './get-chip-status-color'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/types.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/types.ts new file mode 100644 index 0000000000..79e9900eb0 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/types.ts @@ -0,0 +1,24 @@ +export enum MyJobStatus { + ACTIVE = 'ACTIVE', + CANCELED = 'CANCELED', + COMPLETED = 'COMPLETED', + VALIDATION = 'VALIDATION', + EXPIRED = 'EXPIRED', + REJECTED = 'REJECTED', +} + +export const UNKNOWN_JOB_STATUS = 'UNKNOWN'; + +export enum SortField { + CHAIN_ID = 'chain_id', + JOB_TYPE = 'job_type', + REWARD_AMOUNT = 'reward_amount', + CREATED_AT = 'created_at', + ESCROW_ADDRESS = 'escrow_address', + EXPIRES_AT = 'expires_at', +} + +export enum SortDirection { + ASC = 'asc', + DESC = 'desc', +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration.page.tsx index a13c018cf5..2e42c48248 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration.page.tsx @@ -4,7 +4,7 @@ import { Navigate, useParams } from 'react-router-dom'; import { RegistrationForm } from '@/modules/worker/oracle-registration/registration-form'; import { Loader } from '@/shared/components/ui/loader'; import { routerPaths } from '@/router/router-paths'; -import { useGetOracles } from '../jobs-discovery'; +import { useGetOracles } from '../hooks'; import { useIsAlreadyRegistered } from './hooks'; function isAddress(address: string | undefined): address is string { diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/mocks/available-jobs-table-service-mock.ts b/packages/apps/human-app/frontend/src/modules/worker/services/mocks/available-jobs-table-service-mock.ts deleted file mode 100644 index 71af139f0f..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/services/mocks/available-jobs-table-service-mock.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable camelcase -- output from api */ -export interface AvailableJobs { - page: number; - page_size: number; - total_pages: number; - total_results: number; - results: JobsArray[]; -} - -export interface JobsArray { - escrow_address: string; - chain_id: number; - job_type: string; - status: string; -} - -const data: AvailableJobs = { - page: 0, - page_size: 5, - total_pages: 2, - total_results: 7, - results: [ - { - escrow_address: '0x2db00C8A1793424e35f6Cc634Eb13CC174929A4A', - chain_id: 80002, - job_type: 'fortune', - status: 'active', - }, - { - escrow_address: '0x7Cf6978f8699Cf22a121B6332BDF3c5C2F10e3e3', - chain_id: 80002, - job_type: 'fortune', - status: 'active', - }, - { - escrow_address: '0xb389ac3678bF3723863dF92B5D531b0d12e82431', - chain_id: 80002, - job_type: 'fortune', - status: 'active', - }, - { - escrow_address: '0xe9B9b198b093A078Fe8900b703637C26FD2f06a4', - chain_id: 80002, - job_type: 'fortune', - status: 'active', - }, - { - escrow_address: '0x531e2CDB13f2c5606F8C251799f93CBb1219C14C', - chain_id: 80002, - job_type: 'fortune', - status: 'active', - }, - ], -}; - -export function getJobsTableData(): Promise { - return Promise.resolve(data); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/mocks/my-jobs-table-service-mock.ts b/packages/apps/human-app/frontend/src/modules/worker/services/mocks/my-jobs-table-service-mock.ts deleted file mode 100644 index 9505dd28fc..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/services/mocks/my-jobs-table-service-mock.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable camelcase -- output from api */ -export interface MyJobs { - page: number; - page_size: number; - total_pages: number; - total_results: number; - results: JobsArray[]; -} - -interface JobsArray { - assignment_id: number; - escrow_address: string; - chain_id: number; - job_type: string; - status: string; - reward_amount: number; - reward_token: string; - created_at: string; - expires_at: string; - url: string; -} - -const data: MyJobs = { - page: 0, - page_size: 5, - total_pages: 1, - total_results: 2, - results: [ - { - assignment_id: 8, - escrow_address: '0x2db00C8A1793424e35f6Cc634Eb13CC174929A4A', - chain_id: 80002, - job_type: 'fortune', - status: 'active', - reward_amount: 14.004735281093245, - reward_token: 'HMT', - created_at: '2024-04-22T14:38:03.956Z', - expires_at: '2024-07-25T06:05:16.000Z', - url: 'http://stg-fortune-exchange-oracle-server.humanprotocol.org', - }, - { - assignment_id: 9, - escrow_address: '0xb389ac3678bF3723863dF92B5D531b0d12e82431', - chain_id: 80002, - job_type: 'fortune', - status: 'active', - reward_amount: 14.550093644402695, - reward_token: 'HMT', - created_at: '2024-04-23T08:24:14.274Z', - expires_at: '2024-07-27T14:25:15.000Z', - url: 'http://stg-fortune-exchange-oracle-server.humanprotocol.org', - }, - ], -}; - -export function getJobsTableData(): Promise { - return Promise.resolve(data); -} diff --git a/packages/apps/human-app/frontend/src/router/router-paths.ts b/packages/apps/human-app/frontend/src/router/router-paths.ts index 05fdf5cdbf..530a26bc7f 100644 --- a/packages/apps/human-app/frontend/src/router/router-paths.ts +++ b/packages/apps/human-app/frontend/src/router/router-paths.ts @@ -11,7 +11,7 @@ export const routerPaths = { verifyEmail: '/worker/verify-email', profile: '/worker/profile', jobsDiscovery: '/worker/jobs-discovery', - jobs: '/worker/jobs', + jobs: '/worker/jobs-discovery', HcaptchaLabeling: '/worker/hcaptcha-labeling', enableLabeler: '/worker/enable-labeler', registrationInExchangeOracle: '/worker/registration-in-exchange-oracle', diff --git a/packages/apps/human-app/frontend/src/router/routes.tsx b/packages/apps/human-app/frontend/src/router/routes.tsx index 42e4b02b67..53259ce840 100644 --- a/packages/apps/human-app/frontend/src/router/routes.tsx +++ b/packages/apps/human-app/frontend/src/router/routes.tsx @@ -5,7 +5,6 @@ import { SendResetLinkWorkerSuccessPage } from '@/modules/worker/views/send-rese import { ResetPasswordWorkerPage } from '@/modules/worker/views/reset-password/reset-password.page'; import { SendResetLinkWorkerPage } from '@/modules/worker/views/send-reset-link/send-reset-link.page'; import { ResetPasswordWorkerSuccessPage } from '@/modules/worker/views/reset-password/reset-password-success.page'; -import { JobsPage } from '@/modules/worker/views/jobs/jobs.page'; import { env } from '@/shared/env'; import { RegistrationPage } from '@/modules/worker/oracle-registration'; import { @@ -36,6 +35,7 @@ import { EditExistingKeysSuccessPage, SetUpOperatorPage, } from '@/modules/signup/operator'; +import { JobsPage } from '@/modules/worker/jobs'; export const unprotectedRoutes: RouteProps[] = [ { diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx index e5f001079b..fb61df67d0 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx @@ -1,8 +1,7 @@ import React, { forwardRef, useState } from 'react'; import Popover from '@mui/material/Popover'; import type { TableCellBaseProps } from '@mui/material/TableCell/TableCell'; -import type { IconType } from '@/modules/worker/components/jobs/text-header-with-icon'; -import { TextHeaderWithIcon } from '@/modules/worker/components/jobs/text-header-with-icon'; +import { type IconType, TextHeaderWithIcon } from '../text-header-with-icon'; type CommonProps = TableCellBaseProps & { popoverContent: React.ReactElement; diff --git a/packages/apps/human-app/frontend/src/modules/worker/components/jobs/text-header-with-icon.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/text-header-with-icon.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/components/jobs/text-header-with-icon.tsx rename to packages/apps/human-app/frontend/src/shared/components/ui/text-header-with-icon.tsx diff --git a/packages/apps/human-app/frontend/src/shared/hooks/index.ts b/packages/apps/human-app/frontend/src/shared/hooks/index.ts new file mode 100644 index 0000000000..2818199440 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/hooks/index.ts @@ -0,0 +1,8 @@ +export * from './use-combine-pages'; +export * from './use-count-down'; +export * from './use-handle-main-nav-icon-click'; +export * from './use-is-hcaptcha-labeling-page'; +export * from './use-notification'; +export * from './use-is-mobile'; +export * from './use-reset-mutation-errors'; +export * from './use-web3-provider'; diff --git a/packages/apps/human-app/frontend/src/shared/hooks/use-combine-pages.ts b/packages/apps/human-app/frontend/src/shared/hooks/use-combine-pages.ts new file mode 100644 index 0000000000..74a5ac32be --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/hooks/use-combine-pages.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +export function useCombinePages( + tableData: { pages: { results: T[] }[] } | undefined, + page: number +) { + const [allPages, setAllPages] = useState([]); + + useEffect(() => { + if (!tableData) return; + const pagesFromRes = tableData.pages.flatMap((pages) => pages.results); + + if (page === 0) { + setAllPages(pagesFromRes); + } else { + setAllPages((state) => [...state, ...pagesFromRes]); + } + }, [tableData, page]); + + return allPages; +} From bebf66b171927543b36c27ff83e6d7d47c081002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Tue, 11 Mar 2025 16:52:08 +0100 Subject: [PATCH 13/29] [Job Launcher Server] Move withdrawal creation logic to payments module (#3147) * refactor: Move withdrawal creation logic to payments module * refactor: Add balance check to createWithdrawalPayment and remove unused pending job status * refactor: Update job creation logic to associate payment entity directly and streamline job repository insert * Fix failing tests in job service and remove unused paymentRepository from JobService * test: Refactor withdrawal payment tests to use updated payment repository methods --- .../server/src/common/constants/errors.ts | 2 +- .../server/src/common/constants/index.ts | 1 - .../server/src/common/enums/job.ts | 1 - .../transform-enum.interceptor.spec.ts | 18 +++--- .../1741181015584-removePendingJobStatus.ts | 60 ++++++++++++++++++ .../modules/cron-job/cron-job.service.spec.ts | 2 +- .../server/src/modules/job/job.entity.ts | 2 +- .../server/src/modules/job/job.repository.ts | 9 +-- .../src/modules/job/job.service.spec.ts | 59 ++++++------------ .../server/src/modules/job/job.service.ts | 53 ++++------------ .../src/modules/payment/payment.entity.ts | 6 +- .../modules/payment/payment.service.spec.ts | 62 +++++++++++++++++++ .../src/modules/payment/payment.service.ts | 29 ++++++++- 13 files changed, 197 insertions(+), 107 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/database/migrations/1741181015584-removePendingJobStatus.ts diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 259c5fae44..c37c1e5ed6 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -4,7 +4,6 @@ export enum ErrorJob { NotFound = 'Job not found', NotCreated = 'Job has not been created', - NotEnoughFunds = 'Not enough funds', NotActiveCard = 'Credit card not found', ManifestNotFound = 'Manifest not found', ManifestValidationFailed = 'Manifest validation failed', @@ -91,6 +90,7 @@ export enum ErrorPayment { NotFound = 'Payment not found', InvoiceNotFound = 'Invoice not found', NotSuccess = 'Unsuccessful payment', + NotEnoughFunds = 'Not enough funds', IntentNotCreated = 'Payment intent not created', CardNotAssigned = 'Card not assigned', SetupNotFound = 'Setup not found', diff --git a/packages/apps/job-launcher/server/src/common/constants/index.ts b/packages/apps/job-launcher/server/src/common/constants/index.ts index 64c81444bd..5489eb98b4 100644 --- a/packages/apps/job-launcher/server/src/common/constants/index.ts +++ b/packages/apps/job-launcher/server/src/common/constants/index.ts @@ -34,7 +34,6 @@ export const CVAT_JOB_TYPES = [ ]; export const CANCEL_JOB_STATUSES = [ - JobStatus.PENDING, JobStatus.PAID, JobStatus.FAILED, JobStatus.LAUNCHED, diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index 263d1f7b3e..070469367b 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -1,5 +1,4 @@ export enum JobStatus { - PENDING = 'pending', PAID = 'paid', CREATED = 'created', FUNDED = 'funded', diff --git a/packages/apps/job-launcher/server/src/common/interceptors/transform-enum.interceptor.spec.ts b/packages/apps/job-launcher/server/src/common/interceptors/transform-enum.interceptor.spec.ts index 0710491f89..bc47543f2c 100644 --- a/packages/apps/job-launcher/server/src/common/interceptors/transform-enum.interceptor.spec.ts +++ b/packages/apps/job-launcher/server/src/common/interceptors/transform-enum.interceptor.spec.ts @@ -47,13 +47,13 @@ describe('TransformEnumInterceptor', () => { switchToHttp: jest.fn().mockReturnValue({ getRequest: jest.fn().mockReturnValue({ body: { - status: 'PENDING', + status: 'PAID', userType: 'OPERATOR', amount: 5, address: '0xCf88b3f1992458C2f5a229573c768D0E9F70C44e', }, query: { - status: 'PENDING', + status: 'PAID', userType: 'OPERATOR', }, }), @@ -97,9 +97,9 @@ describe('TransformEnumInterceptor', () => { // Expectations expect(request.query.userType).toBe('operator'); - expect(request.query.status).toBe('pending'); + expect(request.query.status).toBe('paid'); expect(request.query).toEqual({ - status: 'pending', + status: 'paid', userType: 'operator', }); expect(callHandler.handle).toBeCalled(); // Ensure the handler is called @@ -136,9 +136,9 @@ describe('TransformEnumInterceptor', () => { // Expectations expect(request.body.userType).toBe('operator'); // Should be transformed to lowercase - expect(request.body.status).toBe('pending'); // Should be transformed to lowercase + expect(request.body.status).toBe('paid'); // Should be transformed to lowercase expect(request.body).toEqual({ - status: 'pending', + status: 'paid', userType: 'operator', amount: 5, address: '0xCf88b3f1992458C2f5a229573c768D0E9F70C44e', @@ -191,7 +191,7 @@ describe('TransformEnumInterceptor', () => { getRequest: jest.fn().mockReturnValue({ body: { transaction: { - status: 'PENDING', + status: 'PAID', userType: 'OPERATOR', address: '0xCf88b3f1992458C2f5a229573c768D0E9F70C44e', }, @@ -208,11 +208,11 @@ describe('TransformEnumInterceptor', () => { const request = executionContext.switchToHttp().getRequest(); // Expectations - expect(request.body.transaction.status).toBe('pending'); // Nested enum should be transformed + expect(request.body.transaction.status).toBe('paid'); // Nested enum should be transformed expect(request.body.transaction.userType).toBe('operator'); expect(request.body).toEqual({ transaction: { - status: 'pending', + status: 'paid', userType: 'operator', address: '0xCf88b3f1992458C2f5a229573c768D0E9F70C44e', }, diff --git a/packages/apps/job-launcher/server/src/database/migrations/1741181015584-removePendingJobStatus.ts b/packages/apps/job-launcher/server/src/database/migrations/1741181015584-removePendingJobStatus.ts new file mode 100644 index 0000000000..b66b8d30f6 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1741181015584-removePendingJobStatus.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemovePendingJobStatus1741181015584 implements MigrationInterface { + name = 'RemovePendingJobStatus1741181015584'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_status_enum" + RENAME TO "jobs_status_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_status_enum" AS ENUM( + 'paid', + 'created', + 'funded', + 'launched', + 'partial', + 'completed', + 'failed', + 'to_cancel', + 'canceled' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "status" TYPE "hmt"."jobs_status_enum" USING "status"::"text"::"hmt"."jobs_status_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_status_enum_old" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_status_enum_old" AS ENUM( + 'pending', + 'paid', + 'created', + 'funded', + 'launched', + 'partial', + 'completed', + 'failed', + 'to_cancel', + 'canceled' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "status" TYPE "hmt"."jobs_status_enum_old" USING "status"::"text"::"hmt"."jobs_status_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_status_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_status_enum_old" + RENAME TO "jobs_status_enum" + `); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts index ba06a837c0..8922621491 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts @@ -961,7 +961,7 @@ describe('CronJobService', () => { id: 1, chainId: ChainId.LOCALHOST, escrowAddress: MOCK_ADDRESS, - status: JobStatus.PENDING, + status: JobStatus.PAID, }; escrowEventMock = { diff --git a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts index fc97fc0806..c29709e6f6 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts @@ -62,7 +62,7 @@ export class JobEntity extends BaseEntity implements IJob { public userId: number; @OneToMany(() => PaymentEntity, (payment) => payment.job) - public payment: PaymentEntity; + public payments: PaymentEntity[]; @Column({ type: 'int', default: 0 }) public retriesCount: number; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts index c054f14d56..f52ac27fe1 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts @@ -91,7 +91,7 @@ export class JobRepository extends BaseRepository { where: { userId, status: Not(In([JobStatus.COMPLETED, JobStatus.CANCELED])), - payment: { + payments: { source: paymentSource, }, }, @@ -106,12 +106,7 @@ export class JobRepository extends BaseRepository { switch (data.status) { case JobStatusFilter.PENDING: - statusFilter = [ - JobStatus.PENDING, - JobStatus.PAID, - JobStatus.CREATED, - JobStatus.FUNDED, - ]; + statusFilter = [JobStatus.PAID, JobStatus.CREATED, JobStatus.FUNDED]; break; case JobStatusFilter.CANCELED: statusFilter = [JobStatus.TO_CANCEL, JobStatus.CANCELED]; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index 97e336cfa9..e8f5976ae1 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -3,12 +3,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Encryption } from '@human-protocol/sdk'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; -import { - PaymentCurrency, - PaymentSource, - PaymentStatus, - PaymentType, -} from '../../common/enums/payment'; +import { PaymentCurrency } from '../../common/enums/payment'; import { JobRequestType, JobStatus, @@ -39,7 +34,6 @@ import { mul } from '../../common/utils/decimal'; describe('JobService', () => { let jobService: JobService, paymentService: PaymentService, - paymentRepository: PaymentRepository, jobRepository: JobRepository, rateService: RateService; @@ -110,7 +104,6 @@ describe('JobService', () => { jobService = moduleRef.get(JobService); paymentService = moduleRef.get(PaymentService); - paymentRepository = moduleRef.get(PaymentRepository); rateService = moduleRef.get(RateService); jobRepository = moduleRef.get(JobRepository); }); @@ -123,12 +116,6 @@ describe('JobService', () => { describe('Fortune', () => { describe('Successful job creation', () => { - beforeAll(() => { - jest - .spyOn(paymentService, 'getUserBalanceByCurrency') - .mockResolvedValue(100000000); - }); - afterEach(() => { jest.clearAllMocks(); }); @@ -163,20 +150,16 @@ describe('JobService', () => { fortuneJobDto, ); - expect(paymentRepository.createUnique).toHaveBeenCalledWith({ - userId: userMock.id, - jobId: jobEntityMock.id, - source: PaymentSource.BALANCE, - type: PaymentType.WITHDRAWAL, - currency: fortuneJobDto.paymentCurrency, - amount: expect.any(Number), - rate: await rateService.getRate( + expect(paymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + fortuneJobDto.paymentCurrency, + await rateService.getRate( fortuneJobDto.paymentCurrency, PaymentCurrency.USD, ), - status: PaymentStatus.SUCCEEDED, - }); - expect(jobRepository.createUnique).toHaveBeenCalledWith({ + ); + expect(jobRepository.updateOne).toHaveBeenCalledWith({ chainId: fortuneJobDto.chainId, userId: userMock.id, manifestUrl: MOCK_FILE_URL, @@ -184,14 +167,14 @@ describe('JobService', () => { requestType: JobRequestType.FORTUNE, fee: expect.any(Number), fundAmount: fortuneJobDto.paymentAmount, - status: JobStatus.PENDING, + status: JobStatus.PAID, waitUntil: expect.any(Date), token: fortuneJobDto.escrowFundToken, exchangeOracle: fortuneJobDto.exchangeOracle, recordingOracle: fortuneJobDto.recordingOracle, reputationOracle: fortuneJobDto.reputationOracle, + payments: expect.any(Array), }); - expect(jobRepository.updateOne).toHaveBeenCalledTimes(1); }); it('should create a Fortune job successfully paid and funded with different currencies', async () => { @@ -234,17 +217,13 @@ describe('JobService', () => { fortuneJobDto, ); - expect(paymentRepository.createUnique).toHaveBeenCalledWith({ - userId: userMock.id, - jobId: jobEntityMock.id, - source: PaymentSource.BALANCE, - type: PaymentType.WITHDRAWAL, - currency: fortuneJobDto.paymentCurrency, - amount: expect.any(Number), - rate: paymentToUsdRate, - status: PaymentStatus.SUCCEEDED, - }); - expect(jobRepository.createUnique).toHaveBeenCalledWith({ + expect(paymentService.createWithdrawalPayment).toHaveBeenCalledWith( + userMock.id, + expect.any(Number), + fortuneJobDto.paymentCurrency, + paymentToUsdRate, + ); + expect(jobRepository.updateOne).toHaveBeenCalledWith({ chainId: fortuneJobDto.chainId, userId: userMock.id, manifestUrl: MOCK_FILE_URL, @@ -255,14 +234,14 @@ describe('JobService', () => { mul(fortuneJobDto.paymentAmount, paymentToUsdRate), usdToTokenRate, ), - status: JobStatus.PENDING, + status: JobStatus.PAID, waitUntil: expect.any(Date), token: fortuneJobDto.escrowFundToken, exchangeOracle: fortuneJobDto.exchangeOracle, recordingOracle: fortuneJobDto.recordingOracle, reputationOracle: fortuneJobDto.reputationOracle, + payments: expect.any(Array), }); - expect(jobRepository.updateOne).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 4d1ef5eec4..8e61a06172 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -39,15 +39,9 @@ import { JobCaptchaShapeType, EscrowFundToken, } from '../../common/enums/job'; -import { - FiatCurrency, - PaymentSource, - PaymentStatus, - PaymentType, -} from '../../common/enums/payment'; +import { FiatCurrency } from '../../common/enums/payment'; import { parseUrl } from '../../common/utils'; -import { add, div, lt, mul, max } from '../../common/utils/decimal'; -import { PaymentRepository } from '../payment/payment.repository'; +import { add, div, mul, max } from '../../common/utils/decimal'; import { PaymentService } from '../payment/payment.service'; import { Web3Service } from '../web3/web3.service'; import { @@ -99,7 +93,6 @@ import { listObjectsInBucket, } from '../../common/utils/storage'; import { WebhookDataDto } from '../webhook/webhook.dto'; -import { PaymentEntity } from '../payment/payment.entity'; import { ManifestAction, EscrowAction, @@ -137,7 +130,6 @@ export class JobService { private readonly jobRepository: JobRepository, private readonly webhookRepository: WebhookRepository, private readonly paymentService: PaymentService, - private readonly paymentRepository: PaymentRepository, private readonly serverConfigService: ServerConfigService, private readonly authConfigService: AuthConfigService, private readonly web3ConfigService: Web3ConfigService, @@ -813,18 +805,6 @@ export class JobService { ); const totalPaymentAmount = add(dto.paymentAmount, paymentCurrencyFee); - const userBalance = await this.paymentService.getUserBalanceByCurrency( - user.id, - dto.paymentCurrency, - ); - - if (lt(userBalance, totalPaymentAmount)) { - throw new ControlledError( - ErrorJob.NotEnoughFunds, - HttpStatus.BAD_REQUEST, - ); - } - const fundTokenFee = dto.paymentCurrency === dto.escrowFundToken ? paymentCurrencyFee @@ -873,6 +853,13 @@ export class JobService { }); } + const paymentEntity = await this.paymentService.createWithdrawalPayment( + user.id, + totalPaymentAmount, + dto.paymentCurrency, + paymentCurrencyRate, + ); + const { createManifest } = this.createJobSpecificActions[requestType]; let jobEntity = new JobEntity(); @@ -919,26 +906,12 @@ export class JobService { jobEntity.requestType = requestType; jobEntity.fee = fundTokenFee; // Fee in the token used to funding the escrow jobEntity.fundAmount = fundTokenAmount; // Amount in the token used to funding the escrow + jobEntity.payments = [paymentEntity]; jobEntity.token = dto.escrowFundToken; - jobEntity.status = JobStatus.PENDING; + jobEntity.status = JobStatus.PAID; jobEntity.waitUntil = new Date(); - jobEntity = await this.jobRepository.createUnique(jobEntity); - - const paymentEntity = new PaymentEntity(); - paymentEntity.userId = user.id; - paymentEntity.jobId = jobEntity.id; - paymentEntity.source = PaymentSource.BALANCE; - paymentEntity.type = PaymentType.WITHDRAWAL; - paymentEntity.amount = -totalPaymentAmount; // In the currency used for the payment. - paymentEntity.currency = dto.paymentCurrency; - paymentEntity.rate = paymentCurrencyRate; - paymentEntity.status = PaymentStatus.SUCCEEDED; - - await this.paymentRepository.createUnique(paymentEntity); - - jobEntity.status = JobStatus.PAID; - await this.jobRepository.updateOne(jobEntity); + jobEntity = await this.jobRepository.updateOne(jobEntity); return jobEntity.id; } @@ -1169,8 +1142,6 @@ export class JobService { let status = JobStatus.CANCELED; switch (jobEntity.status) { - case JobStatus.PENDING: - break; case JobStatus.PAID: if (await this.isCronJobRunning(CronJobType.CreateEscrow)) { status = JobStatus.FAILED; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts index 40eaaae7ff..2a9f91b7d2 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; import { NS } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; import { @@ -52,15 +52,13 @@ export class PaymentEntity extends BaseEntity { }) public status: PaymentStatus; - @JoinColumn() @ManyToOne(() => UserEntity, (user) => user.payments) public user: UserEntity; @Column({ type: 'int' }) public userId: number; - @JoinColumn() - @ManyToOne(() => JobEntity, (job) => job.payment) + @ManyToOne(() => JobEntity, (job) => job.payments) public job: JobEntity; @Column({ type: 'int', nullable: true }) diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index a7b4adb0c3..9dda2394af 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -1529,4 +1529,66 @@ describe('PaymentService', () => { ); }); }); + + describe('createWithdrawalPayment', () => { + const userId = 1; + const amount = 100; + const currency = PaymentCurrency.USD; + const rate = 1; + + it('should create a withdrawal payment successfully', async () => { + jest + .spyOn(paymentRepository, 'getUserBalancePayments') + .mockResolvedValue([ + { amount: 100000000, currency }, + ] as PaymentEntity[]); + jest + .spyOn(paymentRepository, 'createUnique') + .mockResolvedValueOnce(undefined as any); + + await expect( + paymentService.createWithdrawalPayment(userId, amount, currency, rate), + ).resolves.not.toThrow(); + + expect(paymentRepository.createUnique).toHaveBeenCalledWith({ + userId, + source: PaymentSource.BALANCE, + type: PaymentType.WITHDRAWAL, + amount: -amount, + currency, + rate, + status: PaymentStatus.SUCCEEDED, + }); + }); + + it('should throw an error if the payment creation fails', async () => { + jest + .spyOn(paymentRepository, 'getUserBalancePayments') + .mockResolvedValue([ + { amount: 100000000, currency }, + ] as PaymentEntity[]); + jest + .spyOn(paymentRepository, 'createUnique') + .mockRejectedValueOnce(new Error('Database error')); + + await expect( + paymentService.createWithdrawalPayment(userId, amount, currency, rate), + ).rejects.toThrow('Database error'); + }); + + it('should throw an error if the user does not have enough balance', async () => { + jest + .spyOn(paymentRepository, 'getUserBalancePayments') + .mockResolvedValue([{ amount: 0, currency }] as PaymentEntity[]); + + await expect( + paymentService.createWithdrawalPayment(userId, amount, currency, rate), + ).rejects.toThrow( + new ControlledError( + ErrorPayment.NotEnoughFunds, + HttpStatus.BAD_REQUEST, + ), + ); + }); + }); }); diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 4241c0418f..383be801e3 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -33,7 +33,7 @@ import { } from '@human-protocol/core/typechain-types'; import { Web3Service } from '../web3/web3.service'; import { CoingeckoTokenId } from '../../common/constants/payment'; -import { div, eq, mul, add } from '../../common/utils/decimal'; +import { div, eq, mul, add, lt } from '../../common/utils/decimal'; import { verifySignature } from '../../common/utils/signature'; import { PaymentEntity } from './payment.entity'; import { ControlledError } from '../../common/errors/controlled'; @@ -521,6 +521,33 @@ export class PaymentService { // return; // } + public async createWithdrawalPayment( + userId: number, + amount: number, + currency: string, + rate: number, + ): Promise { + // Check if the user has enough balance + const userBalance = await this.getUserBalanceByCurrency(userId, currency); + if (lt(userBalance, amount)) { + throw new ControlledError( + ErrorPayment.NotEnoughFunds, + HttpStatus.BAD_REQUEST, + ); + } + + const paymentEntity = new PaymentEntity(); + paymentEntity.userId = userId; + paymentEntity.source = PaymentSource.BALANCE; + paymentEntity.type = PaymentType.WITHDRAWAL; + paymentEntity.amount = -amount; // In the currency used for the payment. + paymentEntity.currency = currency; + paymentEntity.rate = rate; + paymentEntity.status = PaymentStatus.SUCCEEDED; + + return this.paymentRepository.createUnique(paymentEntity); + } + async listUserPaymentMethods(user: UserEntity): Promise { const cards: CardDto[] = []; if (!user.stripeCustomerId) { From dfb60a6765d3c904cc5fb1339a49d5dba0fdcf11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Tue, 11 Mar 2025 17:31:54 +0100 Subject: [PATCH 14/29] [Job Launcher] Update payment info (#3176) * Refactor payment info * implement user balance retrieval in PaymentService * Move balance endpoint from user controller to payment controller and remove user controller * Fix merge problems --- .../src/components/Headers/AuthHeader.tsx | 7 +- .../src/components/Jobs/Create/CreateJob.tsx | 1 - .../components/Jobs/Create/CryptoPayForm.tsx | 238 ++++++++++++------ .../components/Jobs/Create/FiatPayForm.tsx | 148 +++++++---- .../src/components/TokenSelect/index.tsx | 6 +- .../TopUpAccount/CryptoTopUpForm.tsx | 33 ++- .../client/src/state/auth/reducer.ts | 2 +- .../client/src/state/auth/types.ts | 9 +- .../server/src/common/constants/errors.ts | 2 +- .../src/modules/payment/payment.controller.ts | 32 ++- .../server/src/modules/payment/payment.dto.ts | 16 ++ .../modules/payment/payment.service.spec.ts | 128 ++++++---- .../src/modules/payment/payment.service.ts | 53 ++-- .../src/modules/user/user.controller.ts | 61 ----- .../server/src/modules/user/user.dto.ts | 12 - .../server/src/modules/user/user.module.ts | 2 - .../src/modules/user/user.service.spec.ts | 24 +- .../server/src/modules/user/user.service.ts | 16 +- 18 files changed, 470 insertions(+), 320 deletions(-) delete mode 100644 packages/apps/job-launcher/server/src/modules/user/user.controller.ts diff --git a/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx b/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx index f5bb59585c..c6db6b328b 100644 --- a/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx +++ b/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx @@ -137,9 +137,6 @@ export const AuthHeader = () => { > - {/* - Tony Wen - */} {user?.email} @@ -158,8 +155,8 @@ export const AuthHeader = () => { Balance - {user?.balance?.amount || 0}{' '} - {user?.balance?.currency?.toUpperCase()} + ~{user?.balance?.totalUsdAmount.toFixed(2) || 0} + {' USD'} diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx index c609b8e3b1..36b4732a7a 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx @@ -13,7 +13,6 @@ import { HCaptchaJobRequestForm } from './HCaptchaJobRequestForm'; export const CreateJob = () => { const { payMethod, jobRequest, updateJobRequest } = useCreateJobPageUI(); const { chainId } = jobRequest; - console.log(jobRequest); const { chain } = useAccount(); const { switchChainAsync } = useSwitchChain(); diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx index 83c592cf8a..95dde721ba 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx @@ -15,6 +15,7 @@ import { TextField, Typography, } from '@mui/material'; +import { Decimal } from 'decimal.js'; import { ethers } from 'ethers'; import { useEffect, useMemo, useState } from 'react'; import { Address } from 'viem'; @@ -25,7 +26,6 @@ import { usePublicClient, } from 'wagmi'; import { TokenSelect } from '../../../components/TokenSelect'; -import { useTokenRate } from '../../../hooks/useTokenRate'; import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import * as jobService from '../../../services/job'; import * as paymentService from '../../../services/payment'; @@ -44,8 +44,9 @@ export const CryptoPayForm = ({ const { isConnected } = useAccount(); const { chain } = useAccount(); const { jobRequest, goToPrevStep } = useCreateJobPageUI(); - const [tokenAddress, setTokenAddress] = useState(); - const [tokenSymbol, setTokenSymbol] = useState(); + const [paymentTokenAddress, setPaymentTokenAddress] = useState(); + const [paymentTokenSymbol, setPaymentTokenSymbol] = useState(); + const [fundTokenSymbol, setFundTokenSymbol] = useState(); const [payWithAccountBalance, setPayWithAccountBalance] = useState(false); const [amount, setAmount] = useState(); const [isLoading, setIsLoading] = useState(false); @@ -54,7 +55,8 @@ export const CryptoPayForm = ({ const { data: signer } = useWalletClient(); const publicClient = usePublicClient(); const { user } = useAppSelector((state) => state.auth); - const { data: rate } = useTokenRate(tokenSymbol || 'hmt', 'usd'); + const [paymentTokenRate, setPaymentTokenRate] = useState(0); + const [fundTokenRate, setFundTokenRate] = useState(0); useEffect(() => { const fetchJobLauncherData = async () => { @@ -67,6 +69,28 @@ export const CryptoPayForm = ({ fetchJobLauncherData(); }, []); + useEffect(() => { + const fetchRates = async () => { + if (paymentTokenSymbol && fundTokenSymbol) { + if (paymentTokenSymbol === fundTokenSymbol) { + const rate = await paymentService.getRate(paymentTokenSymbol, 'usd'); + setPaymentTokenRate(rate); + setFundTokenRate(rate); + } else { + const paymentRate = await paymentService.getRate( + paymentTokenSymbol, + 'usd', + ); + const fundRate = await paymentService.getRate(fundTokenSymbol, 'usd'); + setPaymentTokenRate(paymentRate); + setFundTokenRate(fundRate); + } + } + }; + + fetchRates(); + }, [paymentTokenSymbol, fundTokenSymbol]); + const { data: jobLauncherFee } = useReadContract({ address: NETWORKS[jobRequest.chainId!]?.kvstoreAddress as Address, abi: KVStoreABI, @@ -79,49 +103,87 @@ export const CryptoPayForm = ({ }, }); - const fundAmount = useMemo(() => { - if (amount && rate) return Number(amount) * rate; + const minFeeToken = useMemo(() => { + if (minFee && paymentTokenRate) + return new Decimal(minFee).div(paymentTokenRate).toNumber(); return 0; - }, [amount, rate]); - const feeAmount = - fundAmount === 0 - ? 0 - : Math.max(minFee, fundAmount * (Number(jobLauncherFee) / 100)); - const totalAmount = fundAmount + feeAmount; - const accountAmount = user?.balance ? Number(user?.balance?.amount) : 0; + }, [minFee, paymentTokenRate]); + + const feeAmount = useMemo(() => { + if (!amount) return 0; + const amountDecimal = new Decimal(amount); + const feeDecimal = new Decimal(jobLauncherFee as string).div(100); + return Decimal.max(minFeeToken, amountDecimal.mul(feeDecimal)).toNumber(); + }, [amount, minFeeToken, jobLauncherFee]); + + const totalAmount = useMemo(() => { + if (!amount) return 0; + return new Decimal(amount).plus(feeAmount).toNumber(); + }, [amount, feeAmount]); + + const totalUSDAmount = useMemo(() => { + if (!totalAmount || !paymentTokenRate) return 0; + return new Decimal(totalAmount).mul(paymentTokenRate).toNumber(); + }, [totalAmount, paymentTokenRate]); + + const conversionRate = useMemo(() => { + if (paymentTokenRate && fundTokenRate) { + return new Decimal(paymentTokenRate).div(fundTokenRate).toNumber(); + } + return 1; + }, [paymentTokenRate, fundTokenRate]); + + const fundAmount = useMemo(() => { + if (!amount || !conversionRate) return 0; + return new Decimal(amount).mul(conversionRate).toNumber(); + }, [amount, conversionRate]); + + const currentBalance = useMemo(() => { + return ( + user?.balance?.balances.find( + (balance) => balance.currency === paymentTokenSymbol, + )?.amount ?? 0 + ); + }, [user, paymentTokenSymbol]); + + const accountAmount = useMemo( + () => new Decimal(currentBalance), + [currentBalance], + ); const balancePayAmount = useMemo(() => { - if (!payWithAccountBalance) return 0; - if (totalAmount < accountAmount) return totalAmount; + if (!payWithAccountBalance) return new Decimal(0); + const totalAmountDecimal = new Decimal(totalAmount); + if (totalAmountDecimal.lessThan(accountAmount)) return totalAmountDecimal; return accountAmount; }, [payWithAccountBalance, totalAmount, accountAmount]); const walletPayAmount = useMemo(() => { - if (!payWithAccountBalance) return totalAmount; - if (totalAmount < accountAmount) return 0; - return totalAmount - accountAmount; + if (!payWithAccountBalance) return new Decimal(totalAmount); + const totalAmountDecimal = new Decimal(totalAmount); + if (totalAmountDecimal.lessThan(accountAmount)) return new Decimal(0); + return totalAmountDecimal.minus(accountAmount); }, [payWithAccountBalance, totalAmount, accountAmount]); - const handleTokenChange = (symbol: string, address: string) => { - setTokenSymbol(symbol); - setTokenAddress(address); - }; - const handlePay = async () => { - if (signer && tokenAddress && amount && jobRequest.chainId && tokenSymbol) { + if ( + signer && + paymentTokenAddress && + amount && + jobRequest.chainId && + paymentTokenSymbol && + fundTokenSymbol + ) { setIsLoading(true); try { - if (walletPayAmount > 0) { - // send HMT token to operator and retrieve transaction hash - const tokenAmount = walletPayAmount / rate; - + if (walletPayAmount.greaterThan(0)) { const hash = await signer.writeContract({ - address: tokenAddress as Address, + address: paymentTokenAddress as Address, abi: HMTokenABI, functionName: 'transfer', args: [ jobLauncherAddress, - ethers.parseUnits(tokenAmount.toString(), 18), + ethers.parseUnits(walletPayAmount.toString(), 18), ], }); @@ -150,17 +212,17 @@ export const CryptoPayForm = ({ await jobService.createFortuneJob( chainId, fortuneRequest, - tokenSymbol, + paymentTokenSymbol, Number(amount), - tokenSymbol, + fundTokenSymbol, ); } else if (jobType === JobType.CVAT && cvatRequest) { await jobService.createCvatJob( chainId, cvatRequest, - tokenSymbol, + paymentTokenSymbol, Number(amount), - tokenSymbol, + fundTokenSymbol, ); } else if (jobType === JobType.HCAPTCHA && hCaptchaRequest) { await jobService.createHCaptchaJob(chainId, hCaptchaRequest); @@ -229,8 +291,13 @@ export const CryptoPayForm = ({ )} { + setPaymentTokenSymbol(symbol); + setPaymentTokenAddress(address); + }} /> + { + setFundTokenSymbol(symbol); + }} + /> @@ -255,51 +331,27 @@ export const CryptoPayForm = ({ borderBottom: '1px solid #E5E7EB', }} > - Account Balance + Balance - ~ {user?.balance?.amount?.toFixed(2) ?? '0'}{' '} - {user?.balance?.currency?.toUpperCase() ?? 'USD'} + ~ {currentBalance?.toFixed(2) ?? '0'}{' '} + {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} - Fund Amount - - ~ {fundAmount?.toFixed(2)} USD - - - - Fees - - ({Number(jobLauncherFee)}%) {feeAmount?.toFixed(2)} USD - - - - Payment method - Balance + Amount - {balancePayAmount.toFixed(2)} USD + {amount} {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} - Crypto Wallet + Fee - {walletPayAmount.toFixed(2)} USD + ({Number(jobLauncherFee)}%) {feeAmount}{' '} + {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + + + + Total payment + + {totalAmount} {paymentTokenSymbol?.toUpperCase() ?? 'HMT'}{' '} + {`(~${totalUSDAmount.toFixed(2)} USD)`} - {/* + + + Payment method + + - Fees - (3.1%) 9.3 USD - */} + Balance + + {balancePayAmount.toString()}{' '} + {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + + - Total - {totalAmount?.toFixed(2)} USD + Crypto Wallet + + {walletPayAmount.toString()}{' '} + {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + + + Fund Amount + + {fundAmount} {fundTokenSymbol?.toUpperCase() ?? 'HMT'} + + @@ -350,8 +438,8 @@ export const CryptoPayForm = ({ onClick={handlePay} disabled={ !isConnected || - !tokenAddress || - !tokenSymbol || + !paymentTokenAddress || + !fundTokenSymbol || !amount || jobRequest.chainId !== chain?.id } diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx index 51365aada0..7c4fe66e58 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx @@ -16,8 +16,8 @@ import { Typography, } from '@mui/material'; import { useElements, useStripe } from '@stripe/react-stripe-js'; +import { Decimal } from 'decimal.js'; import { useEffect, useMemo, useState } from 'react'; - import { Address } from 'viem'; import { useReadContract } from 'wagmi'; import AddCardModal from '../../../components/CreditCard/AddCardModal'; @@ -40,6 +40,7 @@ import { getOperatorAddress, getUserBillingInfo, getUserCards, + getRate, } from '../../../services/payment'; import { useAppDispatch, useAppSelector } from '../../../state'; import { fetchUserBalanceAsync } from '../../../state/auth/reducer'; @@ -81,10 +82,26 @@ export const FiatPayForm = ({ const [totalAmount, setTotalAmount] = useState(0); const [balancePayAmount, setBalancePayAmount] = useState(0); const [creditCardPayAmount, setCreditCardPayAmount] = useState(0); - const [accountAmount] = useState( - user?.balance ? Number(user?.balance?.amount) : 0, - ); const [tokenSymbol, setTokenSymbol] = useState(); + const [tokenRate, setTokenRate] = useState(0); + + const currentBalance = useMemo(() => { + return ( + user?.balance?.balances.find((balance) => balance.currency === 'usd') + ?.amount ?? 0 + ); + }, [user]); + + useEffect(() => { + const fetchRates = async () => { + if (tokenSymbol) { + const rate = await getRate(tokenSymbol, 'usd'); + setTokenRate(rate); + } + }; + + fetchRates(); + }, [tokenSymbol]); const handleTokenChange = (symbol: string, address: string) => { setTokenSymbol(symbol); @@ -125,6 +142,7 @@ export const FiatPayForm = ({ } setLoadingInitialData(false); }; + const { data: jobLauncherFee, error, @@ -152,28 +170,41 @@ export const FiatPayForm = ({ }, [isError, error, showError]); useMemo(() => { - setFundAmount(amount ? Number(amount) : 0); - if (Number(jobLauncherFee) >= 0) - setFeeAmount( - Math.max(minFee, fundAmount * (Number(jobLauncherFee) / 100)), - ); - setTotalAmount(fundAmount + feeAmount); - if (!payWithAccountBalance) setBalancePayAmount(0); - else if (totalAmount < accountAmount) setBalancePayAmount(totalAmount); - else setBalancePayAmount(accountAmount); - - if (!payWithAccountBalance) setCreditCardPayAmount(totalAmount); - else if (totalAmount < accountAmount) setCreditCardPayAmount(0); - else setCreditCardPayAmount(totalAmount - accountAmount); + const amountDecimal = new Decimal(amount || 0); + const tokenRateDecimal = new Decimal(tokenRate || 0); + const jobLauncherFeeDecimal = new Decimal( + (jobLauncherFee as string) || 0, + ).div(100); + const minFeeDecimal = new Decimal(minFee || 0); + + const fundAmountDecimal = amountDecimal.mul(tokenRateDecimal); + setFundAmount(fundAmountDecimal.toNumber()); + + const feeAmountDecimal = Decimal.max( + minFeeDecimal, + amountDecimal.mul(jobLauncherFeeDecimal), + ); + setFeeAmount(feeAmountDecimal.toNumber()); + + const totalAmountDecimal = amountDecimal.plus(feeAmountDecimal); + setTotalAmount(totalAmountDecimal.toNumber()); + + const balancePayAmountDecimal = payWithAccountBalance + ? Decimal.min(totalAmountDecimal, new Decimal(currentBalance)) + : new Decimal(0); + setBalancePayAmount(balancePayAmountDecimal.toNumber()); + + const creditCardPayAmountDecimal = totalAmountDecimal.minus( + balancePayAmountDecimal, + ); + setCreditCardPayAmount(creditCardPayAmountDecimal.toNumber()); }, [ - accountAmount, + currentBalance, amount, - feeAmount, - fundAmount, jobLauncherFee, minFee, payWithAccountBalance, - totalAmount, + tokenRate, ]); const handleSuccessAction = (message: string) => { @@ -227,7 +258,7 @@ export const FiatPayForm = ({ chainId, fortuneRequest, CURRENCY.usd, - fundAmount, + amount, tokenSymbol, ); } else if (jobType === JobType.CVAT && cvatRequest) { @@ -235,7 +266,7 @@ export const FiatPayForm = ({ chainId, cvatRequest, CURRENCY.usd, - fundAmount, + amount, tokenSymbol, ); } else if (jobType === JobType.HCAPTCHA && hCaptchaRequest) { @@ -371,39 +402,25 @@ export const FiatPayForm = ({ Account Balance {user?.balance && ( - ~ {user?.balance?.amount?.toFixed(2)} USD + {currentBalance.toFixed(2)} USD )} - Fees - - ( - {Number(jobLauncherFee) >= 0 - ? `${Number(jobLauncherFee)}%` - : 'loading...'} - ) {feeAmount.toFixed(2)} USD - - - - Payment method - Balance + Amount - {balancePayAmount.toFixed(2)} USD + {amount} HMT + Fee - Credit Card + ( + {Number(jobLauncherFee) >= 0 + ? `${Number(jobLauncherFee)}%` + : 'loading...'} + ) {feeAmount.toFixed(2)} USD + + + Total payment + {totalAmount.toFixed(2)} USD + + + + + Payment method + + + Balance - {creditCardPayAmount.toFixed(2)} USD + {balancePayAmount.toFixed(2)} USD - Total - {totalAmount.toFixed(2)} USD + + Credit Card + + + {creditCardPayAmount.toFixed(2)} USD + + + + Fund Amount + + {fundAmount} {tokenSymbol?.toUpperCase() ?? 'HMT'} + + diff --git a/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx b/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx index 83981852d9..ebfc4089ba 100644 --- a/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx +++ b/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx @@ -32,11 +32,13 @@ export const TokenSelect: FC = (props) => { return ( - Funding token + + {props.label ?? 'Funding token'} + @@ -577,7 +584,11 @@ export const CvatJobRequestForm = () => { error={touched.bpProvider && Boolean(errors.bpProvider)} onBlur={handleBlur} > - AWS + {Object.values(StorageProviders).map((provider) => ( + + {provider.toUpperCase()} + + ))} @@ -597,7 +608,7 @@ export const CvatJobRequestForm = () => { error={touched.bpRegion && Boolean(errors.bpRegion)} onBlur={handleBlur} > - {Object.values(dataRegions).map((region) => ( + {Object.values(bpRegions).map((region) => ( {region} @@ -688,7 +699,11 @@ export const CvatJobRequestForm = () => { error={touched.gtProvider && Boolean(errors.gtProvider)} onBlur={handleBlur} > - AWS + {Object.values(StorageProviders).map((provider) => ( + + {provider.toUpperCase()} + + ))} diff --git a/packages/apps/job-launcher/server/.env.example b/packages/apps/job-launcher/server/.env.example index 84e8cb920e..fee89736ac 100644 --- a/packages/apps/job-launcher/server/.env.example +++ b/packages/apps/job-launcher/server/.env.example @@ -68,3 +68,13 @@ STRIPE_APP_INFO_URL= # Sendgrid SENDGRID_API_KEY= + +# Vision +GOOGLE_PROJECT_ID= +GOOGLE_PRIVATE_KEY= +GOOGLE_CLIENT_EMAIL= +GCV_MODERATION_RESULTS_FILES_PATH= +GCS_MODERATION_RESULTS_BUCKET= + +# Slack +SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL= \ No newline at end of file diff --git a/packages/apps/job-launcher/server/package.json b/packages/apps/job-launcher/server/package.json index e9b7c083a0..27e6410504 100644 --- a/packages/apps/job-launcher/server/package.json +++ b/packages/apps/job-launcher/server/package.json @@ -28,6 +28,8 @@ "generate-env-doc": "ts-node scripts/generate-env-doc.ts" }, "dependencies": { + "@google-cloud/storage": "^7.15.0", + "@google-cloud/vision": "^4.3.2", "@human-protocol/sdk": "*", "@nestjs/axios": "^3.1.2", "@nestjs/common": "^10.2.7", @@ -54,6 +56,7 @@ "joi": "^17.13.3", "json-stable-stringify": "^1.2.1", "nestjs-minio-client": "^2.2.0", + "node-cache": "^5.1.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "8.13.1", @@ -65,6 +68,7 @@ "zxcvbn": "^4.4.2" }, "devDependencies": { + "@faker-js/faker": "^9.5.0", "@golevelup/ts-jest": "^0.6.1", "@nestjs/cli": "^10.3.2", "@nestjs/schematics": "^10.1.3", diff --git a/packages/apps/job-launcher/server/src/common/config/config.module.ts b/packages/apps/job-launcher/server/src/common/config/config.module.ts index 09a2d66182..1f39191864 100644 --- a/packages/apps/job-launcher/server/src/common/config/config.module.ts +++ b/packages/apps/job-launcher/server/src/common/config/config.module.ts @@ -11,6 +11,8 @@ import { S3ConfigService } from './s3-config.service'; import { SendgridConfigService } from './sendgrid-config.service'; import { StripeConfigService } from './stripe-config.service'; import { Web3ConfigService } from './web3-config.service'; +import { SlackConfigService } from './slack-config.service'; +import { VisionConfigService } from './vision-config.service'; @Global() @Module({ @@ -26,6 +28,8 @@ import { Web3ConfigService } from './web3-config.service'; CvatConfigService, PGPConfigService, NetworkConfigService, + SlackConfigService, + VisionConfigService, ], exports: [ ConfigService, @@ -39,6 +43,8 @@ import { Web3ConfigService } from './web3-config.service'; CvatConfigService, PGPConfigService, NetworkConfigService, + SlackConfigService, + VisionConfigService, ], }) export class EnvConfigModule {} diff --git a/packages/apps/job-launcher/server/src/common/config/env-schema.ts b/packages/apps/job-launcher/server/src/common/config/env-schema.ts index b37df9dce7..25f4f7b4b0 100644 --- a/packages/apps/job-launcher/server/src/common/config/env-schema.ts +++ b/packages/apps/job-launcher/server/src/common/config/env-schema.ts @@ -83,4 +83,12 @@ export const envValidator = Joi.object({ //COIN API KEYS RATE_CACHE_TIME: Joi.number().optional(), COINGECKO_API_KEY: Joi.string().optional(), + // Google + GOOGLE_PROJECT_ID: Joi.string().required(), + GOOGLE_PRIVATE_KEY: Joi.string().required(), + GOOGLE_CLIENT_EMAIL: Joi.string().required(), + GCV_MODERATION_RESULTS_FILES_PATH: Joi.string().required(), + GCV_MODERATION_RESULTS_BUCKET: Joi.string().required(), + // Slack + SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL: Joi.string().required(), }); diff --git a/packages/apps/job-launcher/server/src/common/config/slack-config.service.ts b/packages/apps/job-launcher/server/src/common/config/slack-config.service.ts new file mode 100644 index 0000000000..4bccf51aee --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/config/slack-config.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class SlackConfigService { + constructor(private configService: ConfigService) {} + + /** + * The abuse notification webhook URL for sending messages to a Slack channel. + * Required + */ + get abuseNotificationWebhookUrl(): string { + return this.configService.getOrThrow( + 'SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL', + ); + } +} diff --git a/packages/apps/job-launcher/server/src/common/config/vision-config.service.ts b/packages/apps/job-launcher/server/src/common/config/vision-config.service.ts new file mode 100644 index 0000000000..3d39b184b6 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/config/vision-config.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class VisionConfigService { + constructor(private configService: ConfigService) {} + + /** + * The Google Cloud Storage (GCS) path name where temporary async moderation results will be saved. + * Required + */ + get moderationResultsFilesPath(): string { + return this.configService.getOrThrow( + 'GCV_MODERATION_RESULTS_FILES_PATH', + ); + } + + /** + * The Google Cloud Storage (GCS) bucket name where moderation results will be saved. + * Required + */ + get moderationResultsBucket(): string { + return this.configService.getOrThrow( + 'GCV_MODERATION_RESULTS_BUCKET', + ); + } + + /** + * The project ID for connecting to the Google Cloud Vision API. + * Required + */ + get projectId(): string { + return this.configService.getOrThrow('GOOGLE_PROJECT_ID'); + } + + /** + * The private key for authenticating with the Google Cloud Vision API. + * Required + */ + get privateKey(): string { + return this.configService.getOrThrow('GOOGLE_PRIVATE_KEY'); + } + + /** + * The client email used for authenticating requests to the Google Cloud Vision API. + * Required + */ + get clientEmail(): string { + return this.configService.getOrThrow('GOOGLE_CLIENT_EMAIL'); + } +} diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 8ef3f46a23..9168755ee7 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -24,6 +24,23 @@ export enum ErrorJob { CancelWhileProcessing = 'Your job is being processed and cannot be canceled at this moment. Please, wait a few seconds and try again.', } +/** + * Represents error messages associated with a job moderation. + */ +export enum ErrorContentModeration { + ErrorProcessingDataset = 'Error processing dataset', + InappropriateContent = 'Job cannot be processed due to inappropriate content', + ContentModerationFailed = 'Job cannot be processed due to failure in content moderation', + NoDestinationURIFound = 'No destination URI found in the response', + InvalidBucketUrl = 'Invalid bucket URL', + DataMustBeStoredInGCS = 'Data must be stored in Google Cloud Storage', + NoResultsFound = 'No results found', + ResultsParsingFailed = 'Results parsing failed', + JobModerationFailed = 'Job moderation failed', + ProcessContentModerationRequestFailed = 'Process content moderation request failed', + CompleteContentModerationFailed = 'Complete content moderation failed', +} + /** * Represents error messages associated to webhook. */ @@ -129,6 +146,8 @@ export enum ErrorBucket { InvalidRegion = 'Invalid region for the storage provider', EmptyBucket = 'bucketName cannot be empty', FailedToFetchBucketContents = 'Failed to fetch bucket contents', + InvalidGCSUrl = 'Invalid Google Cloud Storage URL', + UrlParsingError = 'URL format is valid but cannot be parsed', } /** diff --git a/packages/apps/job-launcher/server/src/common/constants/index.ts b/packages/apps/job-launcher/server/src/common/constants/index.ts index 5489eb98b4..03989853ef 100644 --- a/packages/apps/job-launcher/server/src/common/constants/index.ts +++ b/packages/apps/job-launcher/server/src/common/constants/index.ts @@ -67,3 +67,7 @@ export const RESEND_EMAIL_VERIFICATION_PATH = '/auth/resend-email-verification'; export const LOGOUT_PATH = '/auth/logout'; export const MUTEX_TIMEOUT = 2000; //ms + +export const GS_PROTOCOL = 'gs://'; +export const GCV_CONTENT_MODERATION_ASYNC_BATCH_SIZE = 100; +export const GCV_CONTENT_MODERATION_BATCH_SIZE_PER_TASK = 2000; diff --git a/packages/apps/job-launcher/server/src/common/enums/content-moderation.ts b/packages/apps/job-launcher/server/src/common/enums/content-moderation.ts new file mode 100644 index 0000000000..d772b1774f --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/enums/content-moderation.ts @@ -0,0 +1,7 @@ +export enum ContentModerationRequestStatus { + PENDING = 'pending', + PROCESSED = 'processed', + POSITIVE_ABUSE = 'positive_abuse', + PASSED = 'passed', + FAILED = 'failed', +} diff --git a/packages/apps/job-launcher/server/src/common/enums/cron-job.ts b/packages/apps/job-launcher/server/src/common/enums/cron-job.ts index 83cdca59d7..8136a7bde7 100644 --- a/packages/apps/job-launcher/server/src/common/enums/cron-job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/cron-job.ts @@ -1,4 +1,5 @@ export enum CronJobType { + ContentModeration = 'content-moderation', CreateEscrow = 'create-escrow', SetupEscrow = 'setup-escrow', FundEscrow = 'fund-escrow', diff --git a/packages/apps/job-launcher/server/src/common/enums/gcv.ts b/packages/apps/job-launcher/server/src/common/enums/gcv.ts new file mode 100644 index 0000000000..600b82abae --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/enums/gcv.ts @@ -0,0 +1,9 @@ +export enum ContentModerationLevel { + VERY_LIKELY = 'VERY_LIKELY', + LIKELY = 'LIKELY', + POSSIBLE = 'POSSIBLE', +} + +export enum ContentModerationFeature { + SAFE_SEARCH_DETECTION = 'SAFE_SEARCH_DETECTION', +} diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index 070469367b..81db79f4d8 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -1,5 +1,8 @@ export enum JobStatus { PAID = 'paid', + UNDER_MODERATION = 'under_moderation', + MODERATION_PASSED = 'moderation_passed', + POSSIBLE_ABUSE_IN_REVIEW = 'possible_abuse_in_review', CREATED = 'created', FUNDED = 'funded', LAUNCHED = 'launched', diff --git a/packages/apps/job-launcher/server/src/common/utils/gcstorage.spec.ts b/packages/apps/job-launcher/server/src/common/utils/gcstorage.spec.ts new file mode 100644 index 0000000000..5100ed5e18 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/utils/gcstorage.spec.ts @@ -0,0 +1,191 @@ +import { + constructGcsPath, + convertToGCSPath, + convertToHttpUrl, + isGCSBucketUrl, +} from './gcstorage'; +import { ErrorBucket } from '../constants/errors'; + +describe('Google Cloud Storage utils', () => { + describe('isGCSBucketUrl', () => { + it('should return true for a valid GCS HTTP URL', () => { + expect( + isGCSBucketUrl( + 'https://valid-bucket-with-file.storage.googleapis.com/object.jpg', + ), + ).toBe(true); + expect( + isGCSBucketUrl('https://valid-bucket.storage.googleapis.com/'), + ).toBe(true); + expect( + isGCSBucketUrl('https://valid-bucket.storage.googleapis.com'), + ).toBe(true); + }); + + it('should return true for a valid GCS gs:// URL', () => { + expect(isGCSBucketUrl('gs://valid-bucket-with-file/object.jpg')).toBe( + true, + ); + expect(isGCSBucketUrl('gs://valid-bucket/')).toBe(true); + expect(isGCSBucketUrl('gs://valid-bucket')).toBe(true); + }); + + it('should return false for an invalid GCS HTTP URL', () => { + expect(isGCSBucketUrl('https://invalid-url.com/object.jpg')).toBe(false); + }); + + it('should return false for an invalid gs:// URL', () => { + expect(isGCSBucketUrl('gs:/invalid-bucket/object.jpg')).toBe(false); + }); + + it('should return false for a completely invalid URL', () => { + expect(isGCSBucketUrl('randomstring')).toBe(false); + }); + + it('should return false for a GCS URL with an invalid bucket name', () => { + expect(isGCSBucketUrl('https://_invalid.storage.googleapis.com')).toBe( + false, + ); + expect(isGCSBucketUrl('gs://sh.storage.googleapis.com')).toBe(false); + expect(isGCSBucketUrl('https://test-.storage.googleapis.com')).toBe( + false, + ); + expect(isGCSBucketUrl('https://-test.storage.googleapis.com')).toBe( + false, + ); + }); + }); + + describe('convertToGCSPath', () => { + it('should convert a valid GCS HTTP URL to a gs:// path', () => { + expect( + convertToGCSPath( + 'https://valid-bucket.storage.googleapis.com/object.jpg', + ), + ).toBe('gs://valid-bucket/object.jpg'); + }); + + it('should convert a valid GCS HTTP URL without an object path to a gs:// bucket path', () => { + expect( + convertToGCSPath('https://valid-bucket.storage.googleapis.com'), + ).toBe('gs://valid-bucket'); + + expect( + convertToGCSPath('https://valid-bucket.storage.googleapis.com/'), + ).toBe('gs://valid-bucket'); + }); + + it('should throw a Error for an invalid GCS URL', () => { + expect(() => + convertToGCSPath('https://invalid-url.com/object.jpg'), + ).toThrow(new Error(ErrorBucket.InvalidGCSUrl)); + }); + + it('should throw a Error for a URL with an invalid bucket name', () => { + expect(() => + convertToGCSPath('https://invalid_bucket.storage.googleapis.com'), + ).toThrow(new Error(ErrorBucket.InvalidGCSUrl)); + }); + }); + + describe('convertToHttpUrl', () => { + it('should convert a gs:// path to a valid HTTP URL', () => { + const result = convertToHttpUrl('gs://valid-bucket/object.jpg'); + expect(result).toBe( + 'https://valid-bucket.storage.googleapis.com/object.jpg', + ); + }); + + it('should convert a gs:// bucket path without an object to an HTTP bucket URL', () => { + expect(convertToHttpUrl('gs://valid-bucket/')).toBe( + 'https://valid-bucket.storage.googleapis.com/', + ); + expect(convertToHttpUrl('gs://valid-bucket')).toBe( + 'https://valid-bucket.storage.googleapis.com/', + ); + }); + + it('should throw a Error for an invalid gs:// path', () => { + expect(() => convertToHttpUrl('invalid-gcs-path')).toThrow( + new Error(ErrorBucket.InvalidGCSUrl), + ); + }); + + it('should throw a Error if the gs:// format is incorrect', () => { + expect(() => convertToHttpUrl('gs:/missing-slash/object.jpg')).toThrow( + new Error(ErrorBucket.InvalidGCSUrl), + ); + }); + + it('should throw a Error for an invalid bucket name in gs:// path', () => { + expect(() => convertToHttpUrl('gs://_invalid/object.jpg')).toThrow( + new Error(ErrorBucket.InvalidGCSUrl), + ); + expect(() => convertToHttpUrl('gs://test-/object.jpg')).toThrow( + new Error(ErrorBucket.InvalidGCSUrl), + ); + }); + }); + + describe('constructGcsPath', () => { + it('should correctly construct a GCS path with multiple segments', () => { + expect(constructGcsPath('my-bucket', 'folder', 'file.jpg')).toBe( + 'gs://my-bucket/folder/file.jpg', + ); + }); + + it('should handle leading and trailing slashes properly', () => { + expect(constructGcsPath('my-bucket/', '/folder/', '/file.jpg')).toBe( + 'gs://my-bucket/folder/file.jpg', + ); + }); + + it('should remove extra slashes and normalize path segments', () => { + expect( + constructGcsPath('my-bucket', '///folder///', '///file.jpg///'), + ).toBe('gs://my-bucket/folder/file.jpg'); + }); + + it('should handle cases where no additional paths are provided', () => { + expect(constructGcsPath('my-bucket')).toBe('gs://my-bucket'); + }); + + it('should handle empty segments gracefully', () => { + expect(constructGcsPath('my-bucket', '', 'file.jpg')).toBe( + 'gs://my-bucket/file.jpg', + ); + }); + + it('should construct a path with nested directories correctly', () => { + expect( + constructGcsPath('my-bucket', 'folder1', 'folder2', 'file.jpg'), + ).toBe('gs://my-bucket/folder1/folder2/file.jpg'); + }); + + it('should not add an extra slash if the base path already ends with one', () => { + expect(constructGcsPath('my-bucket/', 'file.jpg')).toBe( + 'gs://my-bucket/file.jpg', + ); + }); + + it('should correctly handle a single trailing slash in the base path', () => { + expect(constructGcsPath('my-bucket/', '')).toBe('gs://my-bucket'); + }); + + it('should correctly handle a bucket name that already includes gs://', () => { + expect(constructGcsPath('gs://my-bucket', 'folder', 'file.jpg')).toBe( + 'gs://my-bucket/folder/file.jpg', + ); + }); + + it('should correctly handle a bucket name with gs:// and a trailing slash', () => { + expect(constructGcsPath('gs://my-bucket/', 'folder', 'file.jpg')).toBe( + 'gs://my-bucket/folder/file.jpg', + ); + }); + + it('should handle paths that contain only slashes', () => { + expect(constructGcsPath('my-bucket', '/', '/')).toBe('gs://my-bucket'); + }); + }); +}); diff --git a/packages/apps/job-launcher/server/src/common/utils/gcstorage.ts b/packages/apps/job-launcher/server/src/common/utils/gcstorage.ts new file mode 100644 index 0000000000..c7cf098e36 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/utils/gcstorage.ts @@ -0,0 +1,185 @@ +import { GS_PROTOCOL } from '../constants'; +import { ErrorBucket } from '../constants/errors'; + +// Step 1: Define your regular expressions, bucket validation, and URL validation helpers + +/** + * Regex for GCS URL in subdomain format: https://.storage.googleapis.com/ + */ +export const GCS_HTTP_REGEX_SUBDOMAIN = + /^https:\/\/([a-zA-Z0-9\-.]+)\.storage\.googleapis\.com\/?(.*)$/; + +/** + * Regex for GCS URL in path-based format: https://storage.googleapis.com// + */ +export const GCS_HTTP_REGEX_PATH_BASED = + /^https:\/\/storage\.googleapis\.com\/([^/]+)\/?(.*)$/; + +/** + * Regex for GCS URI format: gs:/// + */ +export const GCS_GS_REGEX = /^gs:\/\/([a-zA-Z0-9\-.]+)\/?(.*)$/; + +/** + * Regex that ensures the bucket name follows Google Cloud Storage (GCS) naming rules: + * - Must be between 3 and 63 characters long. + * - Can contain lowercase letters, numbers, dashes (`-`), and dots (`.`). + * - Cannot begin or end with a dash (`-`). + * - Cannot have consecutive periods (`..`). + * - Cannot resemble an IP address (e.g., "192.168.1.1"). + */ +const BUCKET_NAME_REGEX = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; + +// Step 2: Implement the main validation function + +/** + * Validates if a given URL is a valid Google Cloud Storage URL. + * + * Supports: + * - Subdomain format: https://.storage.googleapis.com[/] + * - Path-based format: https://storage.googleapis.com/[/] + * - GCS URI format: gs://[/] + * + * @param url - The URL to validate. + * @returns {boolean} - Returns true if the URL is valid, otherwise false. + */ +export function isGCSBucketUrl(url: string): boolean { + // 1) Quickly check if it's a valid URL in general + if (!isValidUrl(url)) { + return false; + } + + // 2) Try subdomain-based regex first + let httpMatch = url.match(GCS_HTTP_REGEX_SUBDOMAIN); + + // 3) If that fails, try path-based regex + if (!httpMatch) { + httpMatch = url.match(GCS_HTTP_REGEX_PATH_BASED); + } + + // 4) Also check if it matches the gs:// scheme + const gsMatch = url.match(GCS_GS_REGEX); + + // 5) If any HTTP or GS regex matched + if (httpMatch || gsMatch) { + // For HTTP matches, the bucket is captured in group [1]. + // For GS matches, it's also in group [1]. + const bucketName = httpMatch ? httpMatch[1] : gsMatch ? gsMatch[1] : null; + + if (!bucketName || !isValidBucketName(bucketName)) { + return false; + } + + return true; + } + + return false; +} + +/** + * Validates a URL to check if it is a valid Google Cloud Storage URL. + * This function ensures the URL is well-formed and its protocol is one of: + * - `http:` (HTTP URL) + * - `https:` (HTTPS URL) + * - `gs:` (Google Cloud Storage URI) + * + * @param maybeUrl The URL string to be validated. + * @returns A boolean indicating whether the URL is valid and has an allowed protocol. + */ +export function isValidUrl(maybeUrl: string): boolean { + try { + const url = new URL(maybeUrl); + return ['http:', 'https:', 'gs:'].includes(url.protocol); + } catch (_error) { + return false; + } +} + +/** + * Validates a Google Cloud Storage bucket name. + * GCS requires bucket names to: + * - Be 3-63 characters long + * - Contain only lowercase letters, numbers, dashes + * - Not start or end with a dash + */ +function isValidBucketName(bucket: string): boolean { + return BUCKET_NAME_REGEX.test(bucket); +} + +/** + * Converts a valid Google Cloud Storage HTTP URL to a GCS path. + * + * @param url - The HTTP URL to convert. + * @returns {string} - The converted GCS path. + * @throws Error - If the URL is not a valid GCS URL. + */ +export function convertToGCSPath(url: string): string { + if (!isGCSBucketUrl(url)) { + throw new Error(ErrorBucket.InvalidGCSUrl); + } + + let match = url.match(GCS_HTTP_REGEX_SUBDOMAIN); + let bucketName: string | null = null; + let objectPath: string | null = null; + + if (match) { + bucketName = match[1]; + objectPath = match[2] || ''; + } else { + match = url.match(GCS_HTTP_REGEX_PATH_BASED); + if (match) { + bucketName = match[1]; + objectPath = match[2] || ''; + } + } + + if (!bucketName) { + throw new Error(ErrorBucket.InvalidGCSUrl); + } + + let gcsPath = `gs://${bucketName}`; + if (objectPath) { + gcsPath += `/${objectPath}`; + } + return gcsPath; +} + +/** + * Converts a GCS path to a valid Google Cloud Storage HTTP URL. + * + * @param gcsPath - The GCS path to convert (e.g., "gs://bucket-name/object-path"). + * @returns {string} - The converted HTTP URL. + * @throws Error - If the GCS path is not valid. + */ +export function convertToHttpUrl(gcsPath: string): string { + if (!isGCSBucketUrl(gcsPath)) { + throw new Error(ErrorBucket.InvalidGCSUrl); + } + + const match = gcsPath.match(GCS_GS_REGEX); + + const bucketName = match![1]; + const objectPath = match![2] || ''; + + return `https://${bucketName}.storage.googleapis.com/${objectPath}`; +} + +/** + * Constructs a GCS path with a variable number of segments. + * + * @param bucket - The GCS bucket name (without `gs://`). + * @param paths - Additional path segments to append. + * @returns {string} - The constructed GCS path. + */ +export function constructGcsPath(bucket: string, ...paths: string[]): string { + const cleanBucket = bucket.replace(/^gs:\/\//, '').replace(/\/+$/, ''); + + const fullPath = paths + .map((segment) => segment.replace(/^\/+|\/+$/g, '')) + .filter((segment) => segment) + .join('/'); + + return fullPath + ? `${GS_PROTOCOL}${cleanBucket}/${fullPath}` + : `${GS_PROTOCOL}${cleanBucket}`; +} diff --git a/packages/apps/job-launcher/server/src/common/utils/slack.spec.ts b/packages/apps/job-launcher/server/src/common/utils/slack.spec.ts new file mode 100644 index 0000000000..e7fb29b6e0 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/utils/slack.spec.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import { sendSlackNotification } from './slack'; + +export const MOCK_SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL = + 'https://slack.com/webhook'; + +jest.mock('axios'); +describe('sendSlackNotification', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should send Slack notification and return true on success', async () => { + const mockResponse = { data: 'mockResponseData' }; + (axios.post as jest.Mock).mockResolvedValue(mockResponse); + + const message = 'Test message'; + const result = await sendSlackNotification( + MOCK_SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL, + message, + ); + + expect(axios.post).toHaveBeenCalledWith( + MOCK_SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL, + { + text: message, + }, + ); + + expect(result).toBe(true); + }); + + it('should handle error when sending Slack notification and return false', async () => { + axios.post = jest.fn().mockRejectedValue(new Error('Mock error')); + + const message = 'Test message'; + const result = await sendSlackNotification( + MOCK_SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL, + message, + ); + + expect(axios.post).toHaveBeenCalledWith( + MOCK_SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL, + { + text: message, + }, + ); + + expect(result).toBe(false); + }); + + it('should log payload and return true when webhookUrl is empty', async () => { + const message = 'Test message'; + const result = await sendSlackNotification('', message); + + expect(axios.post).not.toHaveBeenCalled(); + + expect(result).toBe(true); + }); + + it('should log payload and return true when webhookUrl is "disabled"', async () => { + const message = 'Test message'; + const result = await sendSlackNotification('disabled', message); + + expect(axios.post).not.toHaveBeenCalled(); + + expect(result).toBe(true); + }); +}); diff --git a/packages/apps/job-launcher/server/src/common/utils/slack.ts b/packages/apps/job-launcher/server/src/common/utils/slack.ts new file mode 100644 index 0000000000..e4d897ad8f --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/utils/slack.ts @@ -0,0 +1,27 @@ +import { Logger } from '@nestjs/common'; +import axios from 'axios'; + +export async function sendSlackNotification( + webhookUrl: string, + message: string, +): Promise { + const logger = new Logger('Slack'); + + const payload = { + text: message, + }; + + if (!webhookUrl || webhookUrl === 'disabled') { + logger.log('Slack notification (mocked):', payload); + return true; // Simulate success to avoid unnecessary errors + } + + try { + await axios.post(webhookUrl, payload); + logger.log('Slack notification sent:', payload); + return true; + } catch (e) { + logger.error('Error sending Slack notification:', e); + return false; + } +} diff --git a/packages/apps/job-launcher/server/src/common/utils/storage.ts b/packages/apps/job-launcher/server/src/common/utils/storage.ts index a71ead25d1..3efe07783d 100644 --- a/packages/apps/job-launcher/server/src/common/utils/storage.ts +++ b/packages/apps/job-launcher/server/src/common/utils/storage.ts @@ -6,6 +6,10 @@ import { JobRequestType } from '../enums/job'; import axios from 'axios'; import { parseString } from 'xml2js'; import { ControlledError } from '../errors/controlled'; +import { + GCS_HTTP_REGEX_SUBDOMAIN, + GCS_HTTP_REGEX_PATH_BASED, +} from './gcstorage'; export function generateBucketUrl( storageData: StorageDataDto, @@ -18,6 +22,7 @@ export function generateBucketUrl( jobType === JobRequestType.IMAGE_BOXES_FROM_POINTS || jobType === JobRequestType.IMAGE_SKELETONS_FROM_BOXES) && storageData.provider != StorageProviders.AWS && + storageData.provider != StorageProviders.GCS && storageData.provider != StorageProviders.LOCAL ) { throw new ControlledError( @@ -28,6 +33,7 @@ export function generateBucketUrl( if (!storageData.bucketName) { throw new ControlledError(ErrorBucket.EmptyBucket, HttpStatus.BAD_REQUEST); } + switch (storageData.provider) { case StorageProviders.AWS: if (!storageData.region) { @@ -87,6 +93,17 @@ export async function listObjectsInBucket(url: URL): Promise { requestUrl += `/${bucketName}?list-type=2`; + const folderPrefix = folderParts.join('/'); + if (folderPrefix) { + requestUrl += `&prefix=${folderPrefix}`; + } + } else if (GCS_HTTP_REGEX_SUBDOMAIN.test(url.href)) { + requestUrl += `?list-type=2&prefix=${url.pathname.replace(/^\//, '')}`; + } else if (GCS_HTTP_REGEX_PATH_BASED.test(url.href)) { + const pathname = url.pathname.replace(/^\//, ''); + const [bucketName, ...folderParts] = pathname.split('/'); + requestUrl += `/${bucketName}?list-type=2`; + const folderPrefix = folderParts.join('/'); if (folderPrefix) { requestUrl += `&prefix=${folderPrefix}`; diff --git a/packages/apps/job-launcher/server/src/database/database.module.ts b/packages/apps/job-launcher/server/src/database/database.module.ts index e6f30ebc60..cc711645a9 100644 --- a/packages/apps/job-launcher/server/src/database/database.module.ts +++ b/packages/apps/job-launcher/server/src/database/database.module.ts @@ -8,6 +8,7 @@ import { UserEntity } from '../modules/user/user.entity'; import { TypeOrmLoggerModule, TypeOrmLoggerService } from './typeorm'; import { JobEntity } from '../modules/job/job.entity'; +import { ContentModerationRequestEntity } from '../modules/content-moderation/content-moderation-request.entity'; import { PaymentEntity } from '../modules/payment/payment.entity'; import { ServerConfigService } from '../common/config/server-config.service'; import { DatabaseConfigService } from '../common/config/database-config.service'; @@ -15,7 +16,7 @@ import { ApiKeyEntity } from '../modules/auth/apikey.entity'; import { WebhookEntity } from '../modules/webhook/webhook.entity'; import { LoggerOptions } from 'typeorm'; import { CronJobEntity } from '../modules/cron-job/cron-job.entity'; -import { WhitelistEntity } from 'src/modules/whitelist/whitelist.entity'; +import { WhitelistEntity } from '../modules/whitelist/whitelist.entity'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { WhitelistEntity } from 'src/modules/whitelist/whitelist.entity'; ApiKeyEntity, UserEntity, JobEntity, + ContentModerationRequestEntity, PaymentEntity, WebhookEntity, CronJobEntity, diff --git a/packages/apps/job-launcher/server/src/database/migrations/1741773586440-addContentModerationRequests.ts b/packages/apps/job-launcher/server/src/database/migrations/1741773586440-addContentModerationRequests.ts new file mode 100644 index 0000000000..7163fcac46 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1741773586440-addContentModerationRequests.ts @@ -0,0 +1,144 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContentModerationRequests1741773586440 + implements MigrationInterface +{ + name = 'AddContentModerationRequests1741773586440'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "hmt"."content-moderation-requests_status_enum" AS ENUM( + 'pending', + 'processed', + 'positive_abuse', + 'passed', + 'failed' + ) + `); + await queryRunner.query(` + CREATE TABLE "hmt"."content-moderation-requests" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "data_url" character varying NOT NULL, + "from" integer NOT NULL, + "to" integer NOT NULL, + "status" "hmt"."content-moderation-requests_status_enum" NOT NULL, + "job_id" integer NOT NULL, + CONSTRAINT "PK_e81154211cbfb9f8dcd56158313" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_status_enum" + RENAME TO "jobs_status_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_status_enum" AS ENUM( + 'paid', + 'under_moderation', + 'moderation_passed', + 'possible_abuse_in_review', + 'created', + 'funded', + 'launched', + 'partial', + 'completed', + 'failed', + 'to_cancel', + 'canceled' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "status" TYPE "hmt"."jobs_status_enum" USING "status"::"text"::"hmt"."jobs_status_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_status_enum_old" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum" + RENAME TO "cron-jobs_cron_job_type_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum" AS ENUM( + 'content-moderation', + 'create-escrow', + 'setup-escrow', + 'fund-escrow', + 'cancel-escrow', + 'process-pending-webhook', + 'sync-job-statuses', + 'abuse' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."cron-jobs" + ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."cron-jobs_cron_job_type_enum_old" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."content-moderation-requests" + ADD CONSTRAINT "FK_d4f313caf54945a83b00abc02af" FOREIGN KEY ("job_id") REFERENCES "hmt"."jobs"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "hmt"."content-moderation-requests" DROP CONSTRAINT "FK_d4f313caf54945a83b00abc02af" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum_old" AS ENUM( + 'create-escrow', + 'setup-escrow', + 'fund-escrow', + 'cancel-escrow', + 'process-pending-webhook', + 'sync-job-statuses', + 'abuse' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."cron-jobs" + ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum_old" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."cron-jobs_cron_job_type_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum_old" + RENAME TO "cron-jobs_cron_job_type_enum" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_status_enum_old" AS ENUM( + 'paid', + 'created', + 'funded', + 'launched', + 'partial', + 'completed', + 'failed', + 'to_cancel', + 'canceled' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "status" TYPE "hmt"."jobs_status_enum_old" USING "status"::"text"::"hmt"."jobs_status_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_status_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_status_enum_old" + RENAME TO "jobs_status_enum" + `); + await queryRunner.query(` + DROP TABLE "hmt"."content-moderation-requests" + `); + await queryRunner.query(` + DROP TYPE "hmt"."content-moderation-requests_status_enum" + `); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.entity.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.entity.ts new file mode 100644 index 0000000000..70a268f4a7 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.entity.ts @@ -0,0 +1,31 @@ +import { Column, Entity, ManyToOne } from 'typeorm'; +import { NS } from '../../common/constants'; +import { BaseEntity } from '../../database/base.entity'; +import { ContentModerationRequestStatus } from '../../common/enums/content-moderation'; +import { JobEntity } from '../job/job.entity'; + +@Entity({ schema: NS, name: 'content-moderation-requests' }) +export class ContentModerationRequestEntity extends BaseEntity { + @Column({ type: 'varchar', nullable: false }) + public dataUrl: string; + + @Column({ type: 'int', nullable: false }) + public from: number; + + @Column({ type: 'int', nullable: false }) + public to: number; + + @Column({ + type: 'enum', + enum: ContentModerationRequestStatus, + }) + public status: ContentModerationRequestStatus; + + @ManyToOne(() => JobEntity, (job) => job.contentModerationRequests, { + eager: true, + }) + job: JobEntity; + + @Column({ type: 'int', nullable: false }) + public jobId: number; +} diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.repository.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.repository.ts new file mode 100644 index 0000000000..009814295d --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation-request.repository.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { SortDirection } from '../../common/enums/collection'; +import { ContentModerationRequestStatus } from '../../common/enums/content-moderation'; +import { BaseRepository } from '../../database/base.repository'; +import { ContentModerationRequestEntity } from './content-moderation-request.entity'; +import { QueryFailedError } from 'typeorm'; +import { handleQueryFailedError } from '../../common/errors/database'; + +@Injectable() +export class ContentModerationRequestRepository extends BaseRepository { + constructor( + private readonly dataSource: DataSource, + public readonly serverConfigService: ServerConfigService, + ) { + super(ContentModerationRequestEntity, dataSource); + } + + /** + * Finds all requests for a given job, ordered by createdAt desc. + */ + public async findByJobId( + jobId: number, + ): Promise { + try { + return this.find({ + where: { job: { id: jobId } }, + order: { createdAt: SortDirection.DESC }, + relations: ['job', 'job.contentModerationRequests'], + }); + } catch (error) { + if (error instanceof QueryFailedError) { + throw handleQueryFailedError(error); + } + throw error; + } + } + + /** + * Finds requests matching a jobId & status, in descending order by createdAt. + */ + public async findByJobIdAndStatus( + jobId: number, + status: ContentModerationRequestStatus, + ): Promise { + try { + return this.find({ + where: { job: { id: jobId }, status }, + order: { createdAt: SortDirection.DESC }, + relations: ['job', 'job.contentModerationRequests'], + }); + } catch (error) { + if (error instanceof QueryFailedError) { + throw handleQueryFailedError(error); + } + throw error; + } + } + + /** + * Creates multiple new requests in one call. + */ + public async createRequests( + requests: ContentModerationRequestEntity[], + ): Promise { + try { + return await this.save(requests); + } catch (error) { + if (error instanceof QueryFailedError) { + throw handleQueryFailedError(error); + } + throw error; + } + } + + /** + * Updates the status of a single request. + */ + public async updateStatus( + request: ContentModerationRequestEntity, + newStatus: ContentModerationRequestStatus, + ): Promise { + try { + request.status = newStatus; + await this.updateOne(request); + } catch (error) { + if (error instanceof QueryFailedError) { + throw handleQueryFailedError(error); + } + throw error; + } + } +} diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.dto.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.dto.ts new file mode 100644 index 0000000000..eca7f1452a --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.dto.ts @@ -0,0 +1,17 @@ +export class ModerationResultDto { + adult: string; + violence: string; + racy: string; + spoof: string; + medical: string; +} + +export class ImageModerationResultDto { + imageUrl: string; + moderationResult: ModerationResultDto; +} + +export class DataModerationResultDto { + positiveAbuseResults: ImageModerationResultDto[]; + possibleAbuseResults: ImageModerationResultDto[]; +} diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.interface.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.interface.ts new file mode 100644 index 0000000000..7e4b518ba7 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.interface.ts @@ -0,0 +1,5 @@ +import { JobEntity } from '../job/job.entity'; + +export interface IContentModeratorService { + moderateJob(jobEntity: JobEntity): Promise; +} diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.module.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.module.ts new file mode 100644 index 0000000000..9eb83ad901 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/content-moderation.module.ts @@ -0,0 +1,27 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JobModule } from '../job/job.module'; +import { ContentModerationRequestEntity } from './content-moderation-request.entity'; +import { ContentModerationRequestRepository } from './content-moderation-request.repository'; +import { GCVContentModerationService } from './gcv-content-moderation.service'; +import { StorageModule } from '../storage/storage.module'; +import { JobEntity } from '../job/job.entity'; +import { JobRepository } from '../job/job.repository'; + +@Global() +@Module({ + imports: [ + TypeOrmModule.forFeature([ContentModerationRequestEntity, JobEntity]), + ConfigModule, + JobModule, + StorageModule, + ], + providers: [ + ContentModerationRequestRepository, + JobRepository, + GCVContentModerationService, + ], + exports: [GCVContentModerationService], +}) +export class ContentModerationModule {} diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.spec.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.spec.ts new file mode 100644 index 0000000000..0206cfdf30 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.spec.ts @@ -0,0 +1,773 @@ +import { faker } from '@faker-js/faker'; +import { Storage } from '@google-cloud/storage'; +import { ImageAnnotatorClient } from '@google-cloud/vision'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { SlackConfigService } from '../../common/config/slack-config.service'; +import { VisionConfigService } from '../../common/config/vision-config.service'; +import { ErrorContentModeration } from '../../common/constants/errors'; +import { ContentModerationLevel } from '../../common/enums/gcv'; +import { ContentModerationRequestStatus } from '../../common/enums/content-moderation'; +import { JobStatus } from '../../common/enums/job'; +import { ControlledError } from '../../common/errors/controlled'; +import { JobEntity } from '../job/job.entity'; +import { JobRepository } from '../job/job.repository'; +import { StorageService } from '../storage/storage.service'; +import { ContentModerationRequestEntity } from './content-moderation-request.entity'; +import { ContentModerationRequestRepository } from './content-moderation-request.repository'; +import { GCVContentModerationService } from './gcv-content-moderation.service'; +import { sendSlackNotification } from '../../common/utils/slack'; +import { listObjectsInBucket } from '../../common/utils/storage'; + +jest.mock('@google-cloud/storage'); +jest.mock('@google-cloud/vision'); +jest.mock('../../common/utils/slack', () => ({ + sendSlackNotification: jest.fn(), +})); +jest.mock('../../common/utils/storage', () => ({ + ...jest.requireActual('../../common/utils/storage'), + listObjectsInBucket: jest.fn(), +})); + +describe('GCVContentModerationService', () => { + let service: GCVContentModerationService; + + let jobRepository: JobRepository; + let contentModerationRequestRepository: ContentModerationRequestRepository; + let slackConfigService: SlackConfigService; + let storageService: StorageService; + let jobEntity: JobEntity; + + const mockStorage = { + bucket: jest.fn().mockReturnValue({ + getFiles: jest.fn(), + file: jest.fn().mockReturnValue({ + createWriteStream: jest.fn(() => ({ end: jest.fn() })), + getSignedUrl: jest.fn(), + download: jest.fn(), + }), + }), + }; + const mockVisionClient = { + asyncBatchAnnotateImages: jest.fn(), + }; + + beforeAll(async () => { + (Storage as unknown as jest.Mock).mockImplementation(() => mockStorage); + (ImageAnnotatorClient as unknown as jest.Mock).mockImplementation( + () => mockVisionClient, + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GCVContentModerationService, + { + provide: JobRepository, + useValue: { + updateOne: jest.fn(), + }, + }, + { + provide: ContentModerationRequestRepository, + useValue: { + findByJobId: jest.fn(), + findByJobIdAndStatus: jest.fn(), + updateOne: jest.fn(), + }, + }, + { + provide: VisionConfigService, + useValue: { + projectId: faker.string.uuid(), + privateKey: faker.string.alphanumeric(40), + clientEmail: faker.internet.email(), + moderationResultsBucket: faker.word.sample(), + moderationResultsFilesPath: faker.word.sample(), + }, + }, + { + provide: SlackConfigService, + useValue: { + abuseNotificationWebhookUrl: faker.internet.url(), + }, + }, + { + provide: StorageService, + useValue: { + downloadJsonLikeData: jest.fn(), + }, + }, + ], + }).compile(); + service = module.get( + GCVContentModerationService, + ); + jobRepository = module.get(JobRepository); + contentModerationRequestRepository = + module.get( + ContentModerationRequestRepository, + ); + slackConfigService = module.get(SlackConfigService); + storageService = module.get(StorageService); + + jobEntity = { + id: faker.number.int(), + status: JobStatus.PAID, + manifestUrl: faker.internet.url(), + } as JobEntity; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('moderateJob (public)', () => { + it('should call createModerationRequests, processModerationRequests, parseModerationRequests, finalizeJob in order', async () => { + const createModerationRequestsSpy = jest + .spyOn(service, 'createModerationRequests') + .mockResolvedValueOnce(undefined); + const processModerationRequestsSpy = jest + .spyOn(service, 'processModerationRequests') + .mockResolvedValueOnce(undefined); + const parseModerationRequestsSpy = jest + .spyOn(service, 'parseModerationRequests') + .mockResolvedValueOnce(undefined); + const finalizeJobSpy = jest + .spyOn(service, 'finalizeJob') + .mockResolvedValueOnce(undefined); + + await service.moderateJob(jobEntity); + + expect(createModerationRequestsSpy).toHaveBeenCalledWith(jobEntity); + expect(processModerationRequestsSpy).toHaveBeenCalledWith(jobEntity); + expect(parseModerationRequestsSpy).toHaveBeenCalledWith(jobEntity); + expect(finalizeJobSpy).toHaveBeenCalledWith(jobEntity); + }); + + it('should propagate an error if createModerationRequests fails', async () => { + jest + .spyOn(service, 'createModerationRequests') + .mockRejectedValueOnce( + new Error('Simulated createModerationRequests error'), + ); + + await expect(service.moderateJob(jobEntity)).rejects.toThrow( + 'Simulated createModerationRequests error', + ); + }); + }); + + describe('createModerationRequests', () => { + it('should return if job status not PAID or UNDER_MODERATION', async () => { + jobEntity.status = JobStatus.CANCELED; + + await (service as any).createModerationRequests(jobEntity); + expect(jobRepository.updateOne).not.toHaveBeenCalled(); + }); + + it('should set job to MODERATION_PASSED if data_url is missing or invalid', async () => { + jobEntity.status = JobStatus.PAID; + (storageService.downloadJsonLikeData as jest.Mock).mockResolvedValueOnce({ + data: { data_url: null }, + }); + + await (service as any).createModerationRequests(jobEntity); + expect(jobEntity.status).toBe(JobStatus.MODERATION_PASSED); + expect(jobRepository.updateOne).toHaveBeenCalledWith(jobEntity); + }); + + it('should do nothing if no valid files found in GCS', async () => { + jobEntity.status = JobStatus.PAID; + (storageService.downloadJsonLikeData as jest.Mock).mockResolvedValueOnce({ + data: { + data_url: `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}`, + }, + }); + + (listObjectsInBucket as jest.Mock).mockResolvedValueOnce([]); + await (service as any).createModerationRequests(jobEntity); + + expect(jobRepository.updateOne).not.toHaveBeenCalled(); + }); + + it('should create new requests in PENDING and set job to UNDER_MODERATION', async () => { + jobEntity.status = JobStatus.PAID; + (storageService.downloadJsonLikeData as jest.Mock).mockResolvedValueOnce({ + data: { + data_url: `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}`, + }, + }); + + (listObjectsInBucket as jest.Mock).mockResolvedValueOnce([ + `${faker.word.sample()}.jpg`, + `${faker.word.sample()}.jpg`, + `${faker.word.sample()}.jpg`, + ]); + ( + contentModerationRequestRepository.findByJobId as jest.Mock + ).mockResolvedValueOnce([]); + + await (service as any).createModerationRequests(jobEntity); + + expect(jobEntity.status).toBe(JobStatus.UNDER_MODERATION); + expect(jobRepository.updateOne).toHaveBeenCalledWith(jobEntity); + }); + + it('should throw if an error occurs in creation logic', async () => { + jobEntity.status = JobStatus.PAID; + (storageService.downloadJsonLikeData as jest.Mock).mockResolvedValueOnce({ + data: { + data_url: `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}`, + }, + }); + (listObjectsInBucket as jest.Mock).mockResolvedValueOnce([ + `${faker.word.sample()}.jpg`, + `${faker.word.sample()}.jpg`, + `${faker.word.sample()}.jpg`, + ]); + ( + contentModerationRequestRepository.findByJobId as jest.Mock + ).mockRejectedValueOnce(new Error('DB error')); + + await expect( + (service as any).createModerationRequests(jobEntity), + ).rejects.toThrow('DB error'); + }); + }); + + describe('processModerationRequests', () => { + it('should process all PENDING requests (success)', async () => { + const pendingRequest = { + id: faker.number.int(), + } as ContentModerationRequestEntity; + + ( + contentModerationRequestRepository.findByJobIdAndStatus as jest.Mock + ).mockResolvedValueOnce([pendingRequest]); + const processSingleRequestSpy = jest + .spyOn(service, 'processSingleRequest') + .mockResolvedValueOnce(undefined); + + await (service as any).processModerationRequests(jobEntity); + expect(processSingleRequestSpy).toHaveBeenCalledWith(pendingRequest); + }); + + it('should mark request as FAILED if processSingleRequest throws', async () => { + const pendingRequest = { + id: faker.number.int(), + } as ContentModerationRequestEntity; + + ( + contentModerationRequestRepository.findByJobIdAndStatus as jest.Mock + ).mockResolvedValueOnce([pendingRequest]); + jest + .spyOn(service, 'processSingleRequest') + .mockRejectedValueOnce(new Error('Processing error')); + + await (service as any).processModerationRequests(jobEntity); + + expect(contentModerationRequestRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + id: pendingRequest.id, + status: ContentModerationRequestStatus.FAILED, + }), + ); + }); + + it('should throw if findByJobIdAndStatus fails', async () => { + ( + contentModerationRequestRepository.findByJobIdAndStatus as jest.Mock + ).mockRejectedValueOnce(new Error('getRequests error')); + + await expect( + (service as any).processModerationRequests(jobEntity), + ).rejects.toThrow('getRequests error'); + }); + }); + + describe('parseModerationRequests', () => { + it('should parse all PROCESSED requests (success)', async () => { + const processedRequest = { + id: faker.number.int(), + } as ContentModerationRequestEntity; + + ( + contentModerationRequestRepository.findByJobIdAndStatus as jest.Mock + ).mockResolvedValueOnce([processedRequest]); + const parseSingleRequestSpy = jest + .spyOn(service, 'parseSingleRequest') + .mockResolvedValueOnce(undefined); + + await (service as any).parseModerationRequests(jobEntity); + expect(parseSingleRequestSpy).toHaveBeenCalledWith(processedRequest); + }); + + it('should mark request as FAILED if parseSingleRequest throws', async () => { + const processedRequest = { + id: faker.number.int(), + } as ContentModerationRequestEntity; + + ( + contentModerationRequestRepository.findByJobIdAndStatus as jest.Mock + ).mockResolvedValueOnce([processedRequest]); + jest + .spyOn(service, 'parseSingleRequest') + .mockRejectedValueOnce(new Error('Parsing error')); + + await (service as any).parseModerationRequests(jobEntity); + expect(contentModerationRequestRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + id: processedRequest.id, + status: ContentModerationRequestStatus.FAILED, + }), + ); + }); + + it('should throw if findByJobIdAndStatus fails', async () => { + ( + contentModerationRequestRepository.findByJobIdAndStatus as jest.Mock + ).mockRejectedValueOnce(new Error('getRequests error')); + + await expect( + (service as any).parseModerationRequests(jobEntity), + ).rejects.toThrow('getRequests error'); + }); + }); + + describe('finalizeJob', () => { + it('should do nothing if any requests are still PENDING or PROCESSED', async () => { + jobEntity.contentModerationRequests = []; + ( + contentModerationRequestRepository.findByJobId as jest.Mock + ).mockResolvedValueOnce([ + { status: ContentModerationRequestStatus.PROCESSED }, + ]); + + await (service as any).finalizeJob(jobEntity); + expect(jobRepository.updateOne).not.toHaveBeenCalled(); + }); + + it('should set job to MODERATION_PASSED if all requests passed', async () => { + jobEntity.contentModerationRequests = []; + ( + contentModerationRequestRepository.findByJobId as jest.Mock + ).mockResolvedValueOnce([ + { status: ContentModerationRequestStatus.PASSED }, + { status: ContentModerationRequestStatus.PASSED }, + ]); + + await (service as any).finalizeJob(jobEntity); + expect(jobEntity.status).toBe(JobStatus.MODERATION_PASSED); + expect(jobRepository.updateOne).toHaveBeenCalledWith(jobEntity); + }); + + it('should set job to POSSIBLE_ABUSE_IN_REVIEW if any request is flagged', async () => { + jobEntity.contentModerationRequests = []; + ( + contentModerationRequestRepository.findByJobId as jest.Mock + ).mockResolvedValueOnce([ + { status: ContentModerationRequestStatus.POSITIVE_ABUSE }, + ]); + + await (service as any).finalizeJob(jobEntity); + expect(jobEntity.status).toBe(JobStatus.POSSIBLE_ABUSE_IN_REVIEW); + expect(jobRepository.updateOne).toHaveBeenCalledWith(jobEntity); + }); + + it('should throw if DB call fails', async () => { + jobEntity.contentModerationRequests = []; + ( + contentModerationRequestRepository.findByJobId as jest.Mock + ).mockRejectedValueOnce(new Error('DB error')); + + await expect((service as any).finalizeJob(jobEntity)).rejects.toThrow( + 'DB error', + ); + }); + }); + + describe('processSingleRequest', () => { + it('should slice valid files, call asyncBatchAnnotateImages, set status PROCESSED', async () => { + const fakerBucket = faker.word.sample({ length: { min: 5, max: 10 } }); + const requestEntity: ContentModerationRequestEntity = { + id: faker.number.int(), + dataUrl: `https://${fakerBucket}.storage.googleapis.com`, + from: 1, + to: 2, + job: jobEntity, + } as any; + + const file1 = `${faker.word.sample()}.jpg`; + const file2 = `${faker.word.sample()}.jpg`; + const file3 = `${faker.word.sample()}.jpg`; + jest + .spyOn(service, 'getValidFiles') + .mockResolvedValueOnce([file1, file2, file3]); + const asyncBatchSpy = jest + .spyOn(service, 'asyncBatchAnnotateImages') + .mockResolvedValueOnce(undefined); + + await (service as any).processSingleRequest(requestEntity); + + expect(asyncBatchSpy).toHaveBeenCalledWith( + [`gs://${fakerBucket}/${file1}`, `gs://${fakerBucket}/${file2}`], + `moderation-results-${requestEntity.job.id}-${requestEntity.id}`, + ); + expect(contentModerationRequestRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + id: requestEntity.id, + status: ContentModerationRequestStatus.PROCESSED, + }), + ); + }); + + it('should throw if asyncBatchAnnotateImages fails', async () => { + const requestEntity: ContentModerationRequestEntity = { + id: faker.number.int(), + dataUrl: `https://${faker.word.sample({ length: { min: 5, max: 10 } })}.storage.googleapis.com`, + from: 1, + to: 2, + job: jobEntity, + } as any; + + jest + .spyOn(service, 'getValidFiles') + .mockResolvedValueOnce([`${faker.word.sample()}.jpg`]); + jest + .spyOn(service, 'asyncBatchAnnotateImages') + .mockRejectedValueOnce(new Error('Vision error')); + + await expect( + (service as any).processSingleRequest(requestEntity), + ).rejects.toThrow('Vision error'); + }); + }); + + describe('asyncBatchAnnotateImages', () => { + it('should call visionClient.asyncBatchAnnotateImages successfully', async () => { + const mockOperation = { + promise: jest.fn().mockResolvedValueOnce([ + { + outputConfig: { gcsDestination: { uri: faker.internet.url() } }, + }, + ]), + }; + mockVisionClient.asyncBatchAnnotateImages.mockResolvedValueOnce([ + mockOperation, + ]); + + await (service as any).asyncBatchAnnotateImages( + ['img1', 'img2'], + 'my-file', + ); + expect(mockVisionClient.asyncBatchAnnotateImages).toHaveBeenCalledWith( + expect.objectContaining({ requests: expect.any(Array) }), + ); + }); + + it('should throw ControlledError if vision call fails', async () => { + mockVisionClient.asyncBatchAnnotateImages.mockRejectedValueOnce( + new Error('Vision failure'), + ); + + await expect( + (service as any).asyncBatchAnnotateImages([], 'my-file'), + ).rejects.toThrow(ControlledError); + }); + }); + + describe('parseSingleRequest', () => { + it('should set POSITIVE_ABUSE if positiveAbuseResults found', async () => { + const requestEntity: ContentModerationRequestEntity = { + id: faker.number.int(), + job: jobEntity, + } as any; + jest + .spyOn(service, 'collectModerationResults') + .mockResolvedValueOnce([ + { imageUrl: 'abuse.jpg', moderationResult: 'adult' }, + ]); + jest + .spyOn(service, 'handleAbuseLinks') + .mockResolvedValueOnce(undefined); + + await (service as any).parseSingleRequest(requestEntity); + expect(service['handleAbuseLinks']).toHaveBeenCalled(); + expect(requestEntity.status).toBe( + ContentModerationRequestStatus.POSITIVE_ABUSE, + ); + expect(contentModerationRequestRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + status: ContentModerationRequestStatus.POSITIVE_ABUSE, + }), + ); + }); + + it('should set PASSED if no abuse found', async () => { + const requestEntity = { + id: faker.number.int(), + job: jobEntity, + } as ContentModerationRequestEntity; + jest + .spyOn(service, 'collectModerationResults') + .mockResolvedValueOnce({ + positiveAbuseResults: [], + possibleAbuseResults: [], + }); + + await (service as any).parseSingleRequest(requestEntity); + expect(requestEntity.status).toBe(ContentModerationRequestStatus.PASSED); + expect(contentModerationRequestRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + status: ContentModerationRequestStatus.PASSED, + }), + ); + }); + + it('should set FAILED if collectModerationResults throws', async () => { + const requestEntity = { + id: faker.number.int(), + job: jobEntity, + } as ContentModerationRequestEntity; + jest + .spyOn(service, 'collectModerationResults') + .mockRejectedValueOnce(new Error('Collect error')); + + await expect( + (service as any).parseSingleRequest(requestEntity), + ).rejects.toThrow('Collect error'); + expect(requestEntity.status).toBe(ContentModerationRequestStatus.FAILED); + }); + }); + + describe('collectModerationResults', () => { + it('should throw ControlledError if no GCS files found', async () => { + (mockStorage.bucket as any).mockReturnValueOnce({ + getFiles: jest.fn().mockResolvedValueOnce([]), + }); + + await expect( + (service as any).collectModerationResults('some-file'), + ).rejects.toThrow(ErrorContentModeration.NoResultsFound); + }); + + it('should parse each file and accumulate responses, then categorize', async () => { + (mockStorage.bucket as any).mockReturnValueOnce({ + getFiles: jest.fn().mockResolvedValueOnce([ + [ + { + name: `${faker.word.sample()}.json`, + download: jest.fn().mockResolvedValueOnce([ + Buffer.from( + JSON.stringify({ + responses: [ + { + safeSearchAnnotation: { + adult: ContentModerationLevel.LIKELY, + }, + }, + ], + }), + ), + ]), + }, + { + name: `${faker.word.sample()}.json`, + download: jest.fn().mockResolvedValueOnce([ + Buffer.from( + JSON.stringify({ + responses: [ + { + safeSearchAnnotation: { + violence: ContentModerationLevel.POSSIBLE, + }, + }, + ], + }), + ), + ]), + }, + ], + ]), + }); + + jest + .spyOn(service, 'categorizeModerationResults') + .mockReturnValueOnce({ + positiveAbuseResults: [], + possibleAbuseResults: [], + }); + + const result = await (service as any).collectModerationResults( + faker.word.sample(), + ); + expect((service as any).categorizeModerationResults).toHaveBeenCalledWith( + expect.arrayContaining([ + { safeSearchAnnotation: { adult: ContentModerationLevel.LIKELY } }, + { + safeSearchAnnotation: { violence: ContentModerationLevel.POSSIBLE }, + }, + ]), + ); + expect(result).toHaveProperty('positiveAbuseResults'); + expect(result).toHaveProperty('possibleAbuseResults'); + }); + + it('should throw ControlledError if an error occurs', async () => { + (mockStorage.bucket as any).mockReturnValueOnce({ + getFiles: jest.fn().mockRejectedValueOnce(new Error('GCS error')), + }); + + await expect( + (service as any).collectModerationResults(faker.word.sample()), + ).rejects.toThrow(ErrorContentModeration.ResultsParsingFailed); + }); + }); + + describe('categorizeModerationResults', () => { + it('should split results into positiveAbuse and possibleAbuse', () => { + const responses = [ + { + safeSearchAnnotation: { adult: ContentModerationLevel.LIKELY }, + context: { + uri: `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}/${faker.word.sample()}`, + }, + }, + { + safeSearchAnnotation: { violence: ContentModerationLevel.POSSIBLE }, + context: { + uri: `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}/${faker.word.sample()}`, + }, + }, + ]; + const results = (service as any).categorizeModerationResults(responses); + expect(results).toHaveLength(2); + expect(results[0]).toHaveProperty('imageUrl'); + expect(results[0]).toHaveProperty('moderationResult'); + expect(results[1]).toHaveProperty('imageUrl'); + expect(results[1]).toHaveProperty('moderationResult'); + expect(results[0].moderationResult).toBe('adult'); + expect(results[1].moderationResult).toBe('violence'); + }); + + it('should ignore entries with no safeSearchAnnotation', () => { + const responses = [ + { + safeSearchAnnotation: null, + context: { + uri: `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}/${faker.word.sample()}`, + }, + }, + ]; + const results = (service as any).categorizeModerationResults(responses); + expect(results).toHaveLength(0); + }); + }); + + describe('handleAbuseLinks', () => { + it('should upload text file and send Slack message for confirmed abuse', async () => { + const mockSignedUrl = faker.internet.url(); + (mockStorage.bucket as any).mockReturnValueOnce({ + file: jest.fn().mockReturnValueOnce({ + createWriteStream: jest.fn(() => ({ end: jest.fn() })), + getSignedUrl: jest.fn().mockResolvedValueOnce([mockSignedUrl]), + }), + }); + + await (service as any).handleAbuseLinks( + [faker.internet.url()], + faker.word.sample(), + faker.number.int(), + faker.number.int(), + true, + ); + expect(sendSlackNotification).toHaveBeenCalledWith( + slackConfigService.abuseNotificationWebhookUrl, + expect.stringContaining(mockSignedUrl), + ); + }); + + it('should handle possible abuse similarly', async () => { + const mockSignedUrl = faker.internet.url(); + (mockStorage.bucket as any).mockReturnValueOnce({ + file: jest.fn().mockReturnValueOnce({ + createWriteStream: jest.fn(() => ({ end: jest.fn() })), + getSignedUrl: jest.fn().mockResolvedValueOnce([mockSignedUrl]), + }), + }); + + await (service as any).handleAbuseLinks( + [faker.internet.url()], + faker.word.sample(), + faker.number.int(), + faker.number.int(), + false, + ); + expect(sendSlackNotification).toHaveBeenCalledWith( + slackConfigService.abuseNotificationWebhookUrl, + expect.stringContaining(mockSignedUrl), + ); + }); + + it('should throw if getSignedUrl fails', async () => { + (mockStorage.bucket as any).mockReturnValueOnce({ + file: jest.fn().mockReturnValueOnce({ + createWriteStream: jest.fn(() => ({ end: jest.fn() })), + getSignedUrl: jest + .fn() + .mockRejectedValueOnce(new Error('Signed URL error')), + }), + }); + + await expect( + (service as any).handleAbuseLinks( + [], + faker.word.sample(), + faker.number.int(), + faker.number.int(), + true, + ), + ).rejects.toThrow('Signed URL error'); + }); + }); + + describe('getValidFiles', () => { + it('should return cached files if present', async () => { + const dataUrl = `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}/data`; + const file1 = `${faker.word.sample()}.jpg`; + const file2 = `${faker.word.sample()}.png`; + (service as any).bucketListCache.set(dataUrl, [file1, file2]); + + const result = await (service as any).getValidFiles(dataUrl); + expect(result).toEqual([file1, file2]); + expect(listObjectsInBucket).not.toHaveBeenCalled(); + }); + + it('should fetch from GCS if not cached, filter out directories, and cache', async () => { + const dataUrl = `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}/data`; + const file1 = `${faker.word.sample()}.jpg`; + const file2 = `${faker.word.sample()}.png`; + (listObjectsInBucket as jest.Mock).mockResolvedValueOnce([ + file1, + 'subdir/', + file2, + ]); + + const result = await (service as any).getValidFiles(dataUrl); + expect(result).toEqual([file1, file2]); + + expect((service as any).bucketListCache.get(dataUrl)).toEqual(result); + }); + + it('should throw if listObjectsInBucket fails', async () => { + const dataUrl = `gs://${faker.word.sample({ length: { min: 5, max: 10 } })}/fail`; + (listObjectsInBucket as jest.Mock).mockRejectedValueOnce( + new Error('List objects error'), + ); + + await expect((service as any).getValidFiles(dataUrl)).rejects.toThrow( + 'List objects error', + ); + }); + }); +}); diff --git a/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.ts b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.ts new file mode 100644 index 0000000000..f03adf06ba --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/content-moderation/gcv-content-moderation.service.ts @@ -0,0 +1,500 @@ +import { Storage } from '@google-cloud/storage'; +import { ImageAnnotatorClient, protos } from '@google-cloud/vision'; +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { SlackConfigService } from '../../common/config/slack-config.service'; +import { VisionConfigService } from '../../common/config/vision-config.service'; +import { + GCV_CONTENT_MODERATION_ASYNC_BATCH_SIZE, + GCV_CONTENT_MODERATION_BATCH_SIZE_PER_TASK, +} from '../../common/constants'; +import { ErrorContentModeration } from '../../common/constants/errors'; +import { + ContentModerationFeature, + ContentModerationLevel, +} from '../../common/enums/gcv'; +import { ContentModerationRequestStatus } from '../../common/enums/content-moderation'; +import { JobStatus } from '../../common/enums/job'; +import { ControlledError } from '../../common/errors/controlled'; +import { + constructGcsPath, + convertToGCSPath, + convertToHttpUrl, + isGCSBucketUrl, +} from '../../common/utils/gcstorage'; +import { sendSlackNotification } from '../../common/utils/slack'; +import { listObjectsInBucket } from '../../common/utils/storage'; +import { JobEntity } from '../job/job.entity'; +import { JobRepository } from '../job/job.repository'; +import { StorageService } from '../storage/storage.service'; +import { ContentModerationRequestEntity } from './content-moderation-request.entity'; +import { ContentModerationRequestRepository } from './content-moderation-request.repository'; +import { IContentModeratorService } from './content-moderation.interface'; +import { ModerationResultDto } from './content-moderation.dto'; +import NodeCache from 'node-cache'; + +@Injectable() +export class GCVContentModerationService implements IContentModeratorService { + private readonly logger = new Logger(GCVContentModerationService.name); + + private visionClient: ImageAnnotatorClient; + private storage: Storage; + + /** + * Cache of GCS object listings by dataUrl + * Key: dataUrl string, Value: array of valid file names + */ + private bucketListCache: NodeCache; + + constructor( + private readonly jobRepository: JobRepository, + private readonly contentModerationRequestRepository: ContentModerationRequestRepository, + private readonly visionConfigService: VisionConfigService, + private readonly slackConfigService: SlackConfigService, + private readonly storageService: StorageService, + ) { + this.visionClient = new ImageAnnotatorClient({ + projectId: this.visionConfigService.projectId, + credentials: { + private_key: this.visionConfigService.privateKey, + client_email: this.visionConfigService.clientEmail, + }, + }); + + this.storage = new Storage({ + projectId: this.visionConfigService.projectId, + credentials: { + private_key: this.visionConfigService.privateKey, + client_email: this.visionConfigService.clientEmail, + }, + }); + + // Initialize cache with expiration time of 60 minutes and check period of 15 minutes + this.bucketListCache = new NodeCache({ + stdTTL: 30 * 60, + checkperiod: 15 * 60, + }); + } + + /** + * Single public method orchestrating all steps in order + */ + public async moderateJob(jobEntity: JobEntity): Promise { + await this.createModerationRequests(jobEntity); + await this.processModerationRequests(jobEntity); + await this.parseModerationRequests(jobEntity); + await this.finalizeJob(jobEntity); + } + + /** + * 1) If no requests exist for this job, create them in PENDING. + */ + private async createModerationRequests(jobEntity: JobEntity): Promise { + if ( + jobEntity.status !== JobStatus.PAID && + jobEntity.status !== JobStatus.UNDER_MODERATION + ) { + return; + } + + try { + const manifest: any = await this.storageService.downloadJsonLikeData( + jobEntity.manifestUrl, + ); + const dataUrl = manifest?.data?.data_url; + + if (!dataUrl || !isGCSBucketUrl(dataUrl)) { + jobEntity.status = JobStatus.MODERATION_PASSED; + await this.jobRepository.updateOne(jobEntity); + return; + } + + const validFiles = await this.getValidFiles(dataUrl); + if (validFiles.length === 0) return; + + const existingRequests = + await this.contentModerationRequestRepository.findByJobId(jobEntity.id); + + const newRequests: ContentModerationRequestEntity[] = []; + + for ( + let i = 0; + i < validFiles.length; + i += GCV_CONTENT_MODERATION_BATCH_SIZE_PER_TASK + ) { + const from = i + 1; + const to = Math.min( + i + GCV_CONTENT_MODERATION_BATCH_SIZE_PER_TASK, + validFiles.length, + ); + + const request = existingRequests.some( + (req) => req.from === from && req.to === to, + ); + + if (!request) { + newRequests.push( + Object.assign(new ContentModerationRequestEntity(), { + dataUrl, + from, + to, + status: ContentModerationRequestStatus.PENDING, + job: jobEntity, + }), + ); + } + } + + if (newRequests.length > 0) { + jobEntity.contentModerationRequests = [ + ...(jobEntity.contentModerationRequests || []), + ...newRequests, + ]; + jobEntity.status = JobStatus.UNDER_MODERATION; + await this.jobRepository.updateOne(jobEntity); + } + } catch (err) { + this.logger.error( + `Error creating requests for job ${jobEntity.id}: ${err.message}`, + ); + throw err; + } + } + + /** + * 2) Process all PENDING requests -> call GCV. Mark them PROCESSED if success. + * Parallelized with Promise.all for performance. + */ + private async processModerationRequests(jobEntity: JobEntity): Promise { + try { + const requests = + await this.contentModerationRequestRepository.findByJobIdAndStatus( + jobEntity.id, + ContentModerationRequestStatus.PENDING, + ); + await Promise.all( + requests.map(async (requestEntity) => { + try { + await this.processSingleRequest(requestEntity); + } catch (err) { + this.logger.error( + `Error processing request ${requestEntity.id} (job ${jobEntity.id}): ${err.message}`, + ); + requestEntity.status = ContentModerationRequestStatus.FAILED; + await this.contentModerationRequestRepository.updateOne( + requestEntity, + ); + } + }), + ); + } catch (err) { + this.logger.error( + `Error processing requests for job ${jobEntity.id}: ${err.message}`, + ); + throw err; + } + } + + /** + * 3) Parse results for requests in PROCESSED -> set to PASSED, POSSIBLE_ABUSE, or POSITIVE_ABUSE + * Also parallelized with Promise.all. + */ + private async parseModerationRequests(jobEntity: JobEntity): Promise { + try { + const requests = + await this.contentModerationRequestRepository.findByJobIdAndStatus( + jobEntity.id, + ContentModerationRequestStatus.PROCESSED, + ); + + await Promise.all( + requests.map(async (requestEntity) => { + try { + await this.parseSingleRequest(requestEntity); + } catch (err) { + this.logger.error( + `Error parsing request ${requestEntity.id} for job ${jobEntity.id}: ${err.message}`, + ); + requestEntity.status = ContentModerationRequestStatus.FAILED; + await this.contentModerationRequestRepository.updateOne( + requestEntity, + ); + } + }), + ); + } catch (err) { + this.logger.error( + `Error parsing results for job ${jobEntity.id}: ${err.message}`, + ); + throw err; + } + } + + /** + * 4) If all requests are done, set job to MODERATION_PASSED or POSSIBLE_ABUSE_IN_REVIEW + */ + private async finalizeJob(jobEntity: JobEntity): Promise { + try { + // We'll try to use the jobEntity if it has requests loaded. Otherwise, fallback to DB. + const allRequests = jobEntity.contentModerationRequests?.length + ? jobEntity.contentModerationRequests + : await this.contentModerationRequestRepository.findByJobId( + jobEntity.id, + ); + + const incomplete = allRequests.some( + (r) => + r.status === ContentModerationRequestStatus.PENDING || + r.status === ContentModerationRequestStatus.PROCESSED, + ); + if (incomplete) return; + + let allPassed = true; + for (const req of allRequests) { + if ( + req.status === ContentModerationRequestStatus.FAILED || + req.status === ContentModerationRequestStatus.POSITIVE_ABUSE + ) { + allPassed = false; + } + } + + if (allPassed) { + jobEntity.status = JobStatus.MODERATION_PASSED; + await this.jobRepository.updateOne(jobEntity); + } else { + jobEntity.status = JobStatus.POSSIBLE_ABUSE_IN_REVIEW; + await this.jobRepository.updateOne(jobEntity); + } + } catch (err) { + this.logger.error(`Error finalizing job ${jobEntity.id}: ${err.message}`); + throw err; + } + } + + /** + * Actually calls GCV. Mark requestEntity => PROCESSED on success. + */ + private async processSingleRequest( + requestEntity: ContentModerationRequestEntity, + ): Promise { + const validFiles = await this.getValidFiles(requestEntity.dataUrl); + const filesToProcess = validFiles.slice( + requestEntity.from - 1, + requestEntity.to, + ); + const gcDataUrl = convertToGCSPath(requestEntity.dataUrl); + const imageUrls = filesToProcess.map( + (fileName) => `${gcDataUrl}/${fileName.split('/').pop()}`, + ); + + const fileName = `moderation-results-${requestEntity.job.id}-${requestEntity.id}`; + + await this.asyncBatchAnnotateImages(imageUrls, fileName); + + requestEntity.status = ContentModerationRequestStatus.PROCESSED; + await this.contentModerationRequestRepository.updateOne(requestEntity); + } + + /** + * Calls GCV's asyncBatchAnnotateImages with SAFE_SEARCH_DETECTION + */ + private async asyncBatchAnnotateImages( + imageUrls: string[], + fileName: string, + ): Promise { + const request = imageUrls.map((url) => ({ + image: { source: { imageUri: url } }, + features: [{ type: ContentModerationFeature.SAFE_SEARCH_DETECTION }], + })); + + const outputUri = constructGcsPath( + this.visionConfigService.moderationResultsBucket, + this.visionConfigService.moderationResultsFilesPath, + fileName + '-', + ); + + const requestPayload: protos.google.cloud.vision.v1.IAsyncBatchAnnotateImagesRequest = + { + requests: request, + outputConfig: { + gcsDestination: { uri: outputUri }, + batchSize: GCV_CONTENT_MODERATION_ASYNC_BATCH_SIZE, + }, + }; + + try { + const [operation] = + await this.visionClient.asyncBatchAnnotateImages(requestPayload); + const [filesResponse] = await operation.promise(); + this.logger.log( + `Output written to GCS: ${filesResponse?.outputConfig?.gcsDestination?.uri}`, + ); + } catch (error) { + this.logger.error('Error analyzing images:', error); + throw new ControlledError( + ErrorContentModeration.ContentModerationFailed, + HttpStatus.BAD_REQUEST, + ); + } + } + + /** + * Parse a single PROCESSED request => sets it to PASSED or POSITIVE_ABUSE + */ + private async parseSingleRequest( + requestEntity: ContentModerationRequestEntity, + ): Promise { + try { + const fileName = `moderation-results-${requestEntity.job.id}-${requestEntity.id}`; + const moderationResults = await this.collectModerationResults(fileName); + + if (moderationResults.length > 0) { + await this.handleAbuseLinks( + moderationResults, + fileName, + requestEntity.id, + requestEntity.job.id, + ); + requestEntity.status = ContentModerationRequestStatus.POSITIVE_ABUSE; + } else { + requestEntity.status = ContentModerationRequestStatus.PASSED; + } + } catch (err) { + requestEntity.status = ContentModerationRequestStatus.FAILED; + throw err; + } + await this.contentModerationRequestRepository.updateOne(requestEntity); + } + + /** + * Downloads GCS results, categorizes them into positiveAbuse / possibleAbuse + */ + private async collectModerationResults(fileName: string) { + try { + const bucketPrefix = `${this.visionConfigService.moderationResultsFilesPath}/${fileName}`; + const bucketName = this.visionConfigService.moderationResultsBucket; + const bucket = this.storage.bucket(bucketName); + + const [files] = await bucket.getFiles({ prefix: bucketPrefix }); + if (!files || files.length === 0) { + throw new ControlledError( + ErrorContentModeration.NoResultsFound, + HttpStatus.NOT_FOUND, + ); + } + + const allResponses = []; + for (const file of files) { + const [content] = await file.download(); + const jsonString = content.toString('utf-8'); + const parsed = JSON.parse(jsonString); + + if (Array.isArray(parsed.responses)) { + allResponses.push(...parsed.responses); + } + } + return this.categorizeModerationResults(allResponses); + } catch (err) { + this.logger.error('Error collecting moderation results:', err); + if (err instanceof ControlledError) { + throw err; + } else { + throw new ControlledError( + ErrorContentModeration.ResultsParsingFailed, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + + /** + * Processes the results from the Google Cloud Vision API and categorizes them based on moderation levels + */ + private categorizeModerationResults( + results: protos.google.cloud.vision.v1.IAnnotateImageResponse[], + ) { + const relevantLevels = [ + ContentModerationLevel.VERY_LIKELY, + ContentModerationLevel.LIKELY, + ContentModerationLevel.POSSIBLE, + ]; + + return results + .map((response) => { + const safeSearch = response.safeSearchAnnotation as ModerationResultDto; + if (!safeSearch) return null; + + const imageUrl = convertToHttpUrl(response.context?.uri ?? ''); + + const flaggedCategory = Object.keys(new ModerationResultDto()).find( + (field) => + relevantLevels.includes( + safeSearch[ + field as keyof ModerationResultDto + ] as ContentModerationLevel, + ), + ); + + if (!flaggedCategory) { + return null; + } + + return { + imageUrl, + moderationResult: flaggedCategory, + }; + }) + + .filter( + (item): item is { imageUrl: string; moderationResult: string } => + !!item, + ); + } + + /** + * Uploads a small text file listing the abuse-related images, then sends Slack notification + */ + private async handleAbuseLinks( + images: { + imageUrl: string; + moderationResult: string; + }[], + fileName: string, + requestId: number, + jobId: number, + ): Promise { + const bucketName = this.visionConfigService.moderationResultsBucket; + const resultsFileName = `${fileName}.txt`; + const file = this.storage.bucket(bucketName).file(resultsFileName); + const stream = file.createWriteStream({ resumable: false }); + stream.end(JSON.stringify(images)); + + const [signedUrl] = await file.getSignedUrl({ + action: 'read', + expires: Date.now() + 60 * 60 * 24 * 1000, + }); + const consoleUrl = `https://console.cloud.google.com/storage/browser/${bucketName}?prefix=${resultsFileName}`; + const message = `Images may contain abusive content. Request ${requestId}, job ${jobId}.\n\n**Results File:** <${signedUrl}|Download Here>\n**Google Cloud Console:** <${consoleUrl}|View in Console>\n\nEnsure you download the file before the link expires, or access it directly via GCS.`; + + await sendSlackNotification( + this.slackConfigService.abuseNotificationWebhookUrl, + message, + ); + } + + /** + * Caches GCS object listings so we don't repeatedly call listObjectsInBucket for the same dataUrl + */ + private async getValidFiles(dataUrl: string): Promise { + const cacheEntry = this.bucketListCache.get(dataUrl); + if (cacheEntry) { + return cacheEntry; + } + + const allFiles = await listObjectsInBucket(new URL(dataUrl)); + const validFiles = allFiles.filter((f) => f && !f.endsWith('/')); + this.bucketListCache.set(dataUrl, validFiles); + + return validFiles; + } +} diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.module.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.module.ts index f51bd37967..97c54ae941 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.module.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.module.ts @@ -12,12 +12,14 @@ import { WebhookRepository } from '../webhook/webhook.repository'; import { JobEntity } from '../job/job.entity'; import { JobRepository } from '../job/job.repository'; import { ConfigModule } from '@nestjs/config'; +import { ContentModerationModule } from '../content-moderation/content-moderation.module'; @Global() @Module({ imports: [ TypeOrmModule.forFeature([CronJobEntity, JobEntity]), ConfigModule, + ContentModerationModule, JobModule, PaymentModule, Web3Module, diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts index 8922621491..ef3d5615de 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts @@ -2,21 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CronJobType } from '../../common/enums/cron-job'; -import { CronJobService } from './cron-job.service'; -import { CronJobRepository } from './cron-job.repository'; -import { CronJobEntity } from './cron-job.entity'; import { createMock } from '@golevelup/ts-jest'; -import { JobEntity } from '../job/job.entity'; -import { JobRequestType, JobStatus } from '../../common/enums/job'; -import { - MOCK_ADDRESS, - MOCK_EXCHANGE_ORACLE_ADDRESS, - MOCK_EXCHANGE_ORACLE_WEBHOOK_URL, - MOCK_FILE_HASH, - MOCK_FILE_URL, - MOCK_MAX_RETRY_COUNT, - MOCK_TRANSACTION_HASH, -} from '../../../test/constants'; import { ChainId, Encryption, @@ -26,35 +12,56 @@ import { KVStoreUtils, NETWORKS, } from '@human-protocol/sdk'; -import { JobService } from '../job/job.service'; -import { DeepPartial } from 'typeorm'; -import { CvatManifestDto } from '../job/job.dto'; -import { WebhookService } from '../webhook/webhook.service'; -import { StorageService } from '../storage/storage.service'; -import { Web3Service } from '../web3/web3.service'; -import { PaymentService } from '../payment/payment.service'; -import { JobRepository } from '../job/job.repository'; -import { PaymentRepository } from '../payment/payment.repository'; -import { ConfigService } from '@nestjs/config'; -import { RoutingProtocolService } from '../routing-protocol/routing-protocol.service'; -import { WebhookEntity } from '../webhook/webhook.entity'; -import { WebhookStatus } from '../../common/enums/webhook'; -import { WebhookRepository } from '../webhook/webhook.repository'; +import { StatusEvent } from '@human-protocol/sdk/dist/graphql'; import { HttpService } from '@nestjs/axios'; -import { ServerConfigService } from '../../common/config/server-config.service'; +import { HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ethers } from 'ethers'; +import { DeepPartial } from 'typeorm'; +import { + MOCK_ADDRESS, + MOCK_EXCHANGE_ORACLE_ADDRESS, + MOCK_EXCHANGE_ORACLE_WEBHOOK_URL, + MOCK_FILE_HASH, + MOCK_FILE_URL, + MOCK_MAX_RETRY_COUNT, + MOCK_TRANSACTION_HASH, +} from '../../../test/constants'; import { AuthConfigService } from '../../common/config/auth-config.service'; -import { Web3ConfigService } from '../../common/config/web3-config.service'; import { CvatConfigService } from '../../common/config/cvat-config.service'; +import { NetworkConfigService } from '../../common/config/network-config.service'; import { PGPConfigService } from '../../common/config/pgp-config.service'; -import { ErrorCronJob } from '../../common/constants/errors'; +import { ServerConfigService } from '../../common/config/server-config.service'; +import { SlackConfigService } from '../../common/config/slack-config.service'; +import { VisionConfigService } from '../../common/config/vision-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; +import { + ErrorContentModeration, + ErrorCronJob, +} from '../../common/constants/errors'; +import { JobRequestType, JobStatus } from '../../common/enums/job'; +import { WebhookStatus } from '../../common/enums/webhook'; import { ControlledError } from '../../common/errors/controlled'; -import { HttpStatus } from '@nestjs/common'; -import { RateService } from '../rate/rate.service'; -import { StatusEvent } from '@human-protocol/sdk/dist/graphql'; -import { ethers } from 'ethers'; -import { NetworkConfigService } from '../../common/config/network-config.service'; +import { ContentModerationRequestRepository } from '../content-moderation/content-moderation-request.repository'; +import { GCVContentModerationService } from '../content-moderation/gcv-content-moderation.service'; +import { CvatManifestDto } from '../job/job.dto'; +import { JobEntity } from '../job/job.entity'; +import { JobRepository } from '../job/job.repository'; +import { JobService } from '../job/job.service'; +import { PaymentRepository } from '../payment/payment.repository'; +import { PaymentService } from '../payment/payment.service'; import { QualificationService } from '../qualification/qualification.service'; +import { RateService } from '../rate/rate.service'; +import { StorageService } from '../storage/storage.service'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookEntity } from '../webhook/webhook.entity'; +import { WebhookRepository } from '../webhook/webhook.repository'; +import { WebhookService } from '../webhook/webhook.service'; import { WhitelistService } from '../whitelist/whitelist.service'; +import { CronJobEntity } from './cron-job.entity'; +import { CronJobRepository } from './cron-job.repository'; +import { CronJobService } from './cron-job.service'; +import { RoutingProtocolService } from '../routing-protocol/routing-protocol.service'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -80,7 +87,7 @@ describe('CronJobService', () => { webhookRepository: WebhookRepository, storageService: StorageService, jobService: JobService, - // paymentService: PaymentService, + contentModerationService: GCVContentModerationService, jobRepository: JobRepository; const signerMock = { @@ -114,6 +121,7 @@ describe('CronJobService', () => { }, }, JobService, + GCVContentModerationService, WebhookService, Encryption, ServerConfigService, @@ -122,6 +130,17 @@ describe('CronJobService', () => { CvatConfigService, PGPConfigService, NetworkConfigService, + { + provide: VisionConfigService, + useValue: { + projectId: 'test-project-id', + privateKey: 'test-private-key', + clientEmail: 'test-client-email', + tempAsyncResultsBucket: 'test-temp-bucket', + moderationResultsBucket: 'test-moderation-results-bucket', + }, + }, + SlackConfigService, QualificationService, { provide: NetworkConfigService, @@ -130,6 +149,10 @@ describe('CronJobService', () => { }, }, { provide: JobRepository, useValue: createMock() }, + { + provide: ContentModerationRequestRepository, + useValue: createMock(), + }, { provide: PaymentRepository, useValue: createMock(), @@ -156,6 +179,9 @@ describe('CronJobService', () => { service = module.get(CronJobService); // paymentService = module.get(PaymentService); + contentModerationService = module.get( + GCVContentModerationService, + ); jobService = module.get(JobService); jobRepository = module.get(JobRepository); repository = module.get(CronJobRepository); @@ -946,6 +972,113 @@ describe('CronJobService', () => { ); }); }); + + describe('moderateContentCronJob', () => { + let contentModerationMock: any; + let cronJobEntityMock: Partial; + let jobEntity1: Partial, jobEntity2: Partial; + + beforeEach(() => { + cronJobEntityMock = { + cronJobType: CronJobType.ContentModeration, + startedAt: new Date(), + }; + + jobEntity1 = { + id: 1, + status: JobStatus.PAID, + }; + + jobEntity2 = { + id: 2, + status: JobStatus.PAID, + }; + + jest + .spyOn(jobRepository, 'findByStatus') + .mockResolvedValue([jobEntity1 as any, jobEntity2 as any]); + + contentModerationMock = jest.spyOn( + contentModerationService, + 'moderateJob', + ); + contentModerationMock.mockResolvedValue(true); + + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(false); + + jest.spyOn(repository, 'findOneByType').mockResolvedValue(null); + jest + .spyOn(repository, 'createUnique') + .mockResolvedValue(cronJobEntityMock as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should not run if cron job is already running', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValueOnce(true); + + const startCronJobMock = jest.spyOn(service, 'startCronJob'); + + await service.moderateContentCronJob(); + + expect(startCronJobMock).not.toHaveBeenCalled(); + }); + + it('should create a cron job entity to lock the process', async () => { + jest + .spyOn(service, 'startCronJob') + .mockResolvedValueOnce(cronJobEntityMock as any); + + await service.moderateContentCronJob(); + + expect(service.startCronJob).toHaveBeenCalledWith( + CronJobType.ContentModeration, + ); + }); + + it('should process all jobs with status PAID', async () => { + await service.moderateContentCronJob(); + + expect(contentModerationMock).toHaveBeenCalledTimes(2); + expect(contentModerationMock).toHaveBeenCalledWith(jobEntity1); + expect(contentModerationMock).toHaveBeenCalledWith(jobEntity2); + }); + + it('should handle failed moderation attempts', async () => { + const error = new Error('Moderation failed'); + contentModerationMock.mockRejectedValueOnce(error); + + const handleFailureMock = jest.spyOn( + jobService, + 'handleProcessJobFailure', + ); + + await service.moderateContentCronJob(); + + expect(handleFailureMock).toHaveBeenCalledTimes(1); + expect(handleFailureMock).toHaveBeenCalledWith( + jobEntity1, + expect.stringContaining(ErrorContentModeration.ResultsParsingFailed), + ); + expect(handleFailureMock).not.toHaveBeenCalledWith( + jobEntity2, + expect.anything(), + ); + }); + + it('should complete the cron job entity to unlock', async () => { + jest + .spyOn(service, 'completeCronJob') + .mockResolvedValueOnce(cronJobEntityMock as any); + + await service.moderateContentCronJob(); + + expect(service.completeCronJob).toHaveBeenCalledWith(cronJobEntityMock); + }); + }); + describe('syncJobStuses Cron Job', () => { let cronJobEntityMock: Partial; let jobEntityMock: Partial; diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts index 7485b39b03..81314a90c5 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts @@ -1,33 +1,34 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; -import { CronJobType } from '../../common/enums/cron-job'; import { + ErrorContentModeration, ErrorCronJob, ErrorEscrow, - // ErrorJob, } from '../../common/constants/errors'; +import { CronJobType } from '../../common/enums/cron-job'; -import { CronJobEntity } from './cron-job.entity'; -import { CronJobRepository } from './cron-job.repository'; -import { JobService } from '../job/job.service'; +import { EscrowStatus, EscrowUtils } from '@human-protocol/sdk'; +import { Cron } from '@nestjs/schedule'; +import { ethers } from 'ethers'; +import { NetworkConfigService } from '../../common/config/network-config.service'; import { JobStatus } from '../../common/enums/job'; -import { WebhookService } from '../webhook/webhook.service'; import { EventType, OracleType, WebhookStatus, } from '../../common/enums/webhook'; -import { PaymentService } from '../payment/payment.service'; -import { ethers } from 'ethers'; -import { WebhookRepository } from '../webhook/webhook.repository'; -import { WebhookEntity } from '../webhook/webhook.entity'; -import { JobRepository } from '../job/job.repository'; import { ControlledError } from '../../common/errors/controlled'; -import { Cron } from '@nestjs/schedule'; -import { EscrowStatus, EscrowUtils } from '@human-protocol/sdk'; -import { Web3Service } from '../web3/web3.service'; +import { GCVContentModerationService } from '../content-moderation/gcv-content-moderation.service'; import { JobEntity } from '../job/job.entity'; -import { NetworkConfigService } from '../../common/config/network-config.service'; +import { JobRepository } from '../job/job.repository'; +import { JobService } from '../job/job.service'; +import { PaymentService } from '../payment/payment.service'; +import { Web3Service } from '../web3/web3.service'; +import { WebhookEntity } from '../webhook/webhook.entity'; +import { WebhookRepository } from '../webhook/webhook.repository'; +import { WebhookService } from '../webhook/webhook.service'; +import { CronJobEntity } from './cron-job.entity'; +import { CronJobRepository } from './cron-job.repository'; @Injectable() export class CronJobService { @@ -37,6 +38,7 @@ export class CronJobService { private readonly cronJobRepository: CronJobRepository, private readonly jobService: JobService, private readonly jobRepository: JobRepository, + private readonly contentModerationService: GCVContentModerationService, private readonly webhookService: WebhookService, private readonly web3Service: Web3Service, private readonly paymentService: PaymentService, @@ -79,6 +81,46 @@ export class CronJobService { return this.cronJobRepository.updateOne(cronJobEntity); } + @Cron('*/2 * * * *') + public async moderateContentCronJob() { + if (await this.isCronJobRunning(CronJobType.ContentModeration)) { + return; + } + + const cronJobEntity = await this.startCronJob( + CronJobType.ContentModeration, + ); + + try { + const jobs = await this.jobRepository.findByStatus([ + JobStatus.PAID, + JobStatus.UNDER_MODERATION, + ]); + + await Promise.all( + jobs.map(async (jobEntity) => { + try { + await this.contentModerationService.moderateJob(jobEntity); + } catch (err) { + const errorId = uuidv4(); + const failedReason = `${ErrorContentModeration.ResultsParsingFailed} (Error ID: ${errorId})`; + this.logger.error( + `Error parse job moderation results job. Error ID: ${errorId}, Job ID: ${jobEntity.id}, Reason: ${failedReason}, Message: ${err.message}`, + ); + await this.jobService.handleProcessJobFailure( + jobEntity, + failedReason, + ); + } + }), + ); + } catch (err) { + this.logger.error(`Error in moderateContentCronJob: ${err.message}`); + } + + await this.completeCronJob(cronJobEntity); + } + @Cron('*/2 * * * *') public async createEscrowCronJob() { const isCronJobRunning = await this.isCronJobRunning( @@ -93,7 +135,9 @@ export class CronJobService { const cronJob = await this.startCronJob(CronJobType.CreateEscrow); try { - const jobEntities = await this.jobRepository.findByStatus(JobStatus.PAID); + const jobEntities = await this.jobRepository.findByStatus( + JobStatus.MODERATION_PASSED, + ); for (const jobEntity of jobEntities) { try { await this.jobService.createEscrow(jobEntity); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts index c29709e6f6..4207c86d95 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.entity.ts @@ -6,6 +6,7 @@ import { JobRequestType, JobStatus } from '../../common/enums/job'; import { BaseEntity } from '../../database/base.entity'; import { UserEntity } from '../user/user.entity'; import { PaymentEntity } from '../payment/payment.entity'; +import { ContentModerationRequestEntity } from '../content-moderation/content-moderation-request.entity'; @Entity({ schema: NS, name: 'jobs' }) @Index(['chainId', 'escrowAddress'], { unique: true }) @@ -64,6 +65,13 @@ export class JobEntity extends BaseEntity implements IJob { @OneToMany(() => PaymentEntity, (payment) => payment.job) public payments: PaymentEntity[]; + @OneToMany( + () => ContentModerationRequestEntity, + (contentModerationRequest) => contentModerationRequest.job, + { cascade: ['insert'] }, + ) + public contentModerationRequests: ContentModerationRequestEntity[]; + @Column({ type: 'int', default: 0 }) public retriesCount: number; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts index f52ac27fe1..9e9500e4a5 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts @@ -66,12 +66,13 @@ export class JobRepository extends BaseRepository { } public async findByStatus( - status: JobStatus, + status: JobStatus | JobStatus[], take?: number, ): Promise { + const statusCondition = Array.isArray(status) ? In(status) : status; return this.find({ where: { - status: status, + status: statusCondition, retriesCount: LessThanOrEqual(this.serverConfigService.maxRetryCount), waitUntil: LessThanOrEqual(new Date()), }, @@ -80,6 +81,7 @@ export class JobRepository extends BaseRepository { waitUntil: SortDirection.ASC, }, ...(take && { take }), + relations: ['contentModerationRequests'], }); } @@ -106,7 +108,14 @@ export class JobRepository extends BaseRepository { switch (data.status) { case JobStatusFilter.PENDING: - statusFilter = [JobStatus.PAID, JobStatus.CREATED, JobStatus.FUNDED]; + statusFilter = [ + JobStatus.PAID, + JobStatus.UNDER_MODERATION, + JobStatus.MODERATION_PASSED, + JobStatus.POSSIBLE_ABUSE_IN_REVIEW, + JobStatus.CREATED, + JobStatus.FUNDED, + ]; break; case JobStatusFilter.CANCELED: statusFilter = [JobStatus.TO_CANCEL, JobStatus.CANCELED]; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index e8f5976ae1..52a5e4751e 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -111,6 +111,7 @@ describe('JobService', () => { describe('createJob', () => { const userMock: any = { id: 1, + whitlisted: true, stripeCustomerId: 'stripeTest', }; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 8e61a06172..87e20e9eee 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -908,9 +908,14 @@ export class JobService { jobEntity.fundAmount = fundTokenAmount; // Amount in the token used to funding the escrow jobEntity.payments = [paymentEntity]; jobEntity.token = dto.escrowFundToken; - jobEntity.status = JobStatus.PAID; jobEntity.waitUntil = new Date(); + if (user.whitelist) { + jobEntity.status = JobStatus.MODERATION_PASSED; + } else { + jobEntity.status = JobStatus.PAID; + } + jobEntity = await this.jobRepository.updateOne(jobEntity); return jobEntity.id; diff --git a/yarn.lock b/yarn.lock index 5bd189a98a..db52b8a003 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2489,7 +2489,7 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@faker-js/faker@^9.4.0": +"@faker-js/faker@^9.4.0", "@faker-js/faker@^9.5.0": version "9.5.0" resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-9.5.0.tgz#ce254c83706250ca8a5a0e05683608160610dd84" integrity sha512-3qbjLv+fzuuCg3umxc9/7YjrEXNaKwHgmig949nfyaTx8eL4FAsvFbu+1JcFUj1YAXofhaDn6JdEUBTYuk0Ssw== @@ -2555,11 +2555,45 @@ resolved "https://registry.yarnpkg.com/@golevelup/ts-jest/-/ts-jest-0.6.2.tgz#483a482e1ab5a835cdd0f8669f76d1201c4a0f63" integrity sha512-ks82vcWbnRuwHSKlrZTGCPPWXZEKlsn1VA2OiYfJ+tVMcMsI4y9ExWkf7FnmYypYJIRWKS9b9N5QVVrCOmaVlg== +"@google-cloud/paginator@^5.0.0": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-5.0.2.tgz#86ad773266ce9f3b82955a8f75e22cd012ccc889" + integrity sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg== + dependencies: + arrify "^2.0.0" + extend "^3.0.2" + +"@google-cloud/projectify@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@google-cloud/projectify/-/projectify-4.0.0.tgz#d600e0433daf51b88c1fa95ac7f02e38e80a07be" + integrity sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA== + "@google-cloud/promisify@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-4.0.0.tgz#a906e533ebdd0f754dca2509933334ce58b8c8b1" integrity sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g== +"@google-cloud/storage@^7.15.0": + version "7.15.0" + resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-7.15.0.tgz#a9f26314f911a9e7f3d5b443f4bd2304936e80e9" + integrity sha512-/j/+8DFuEOo33fbdX0V5wjooOoFahEaMEdImHBmM2tH9MPHJYNtmXOf2sGUmZmiufSukmBEvdlzYgDkkgeBiVQ== + dependencies: + "@google-cloud/paginator" "^5.0.0" + "@google-cloud/projectify" "^4.0.0" + "@google-cloud/promisify" "^4.0.0" + abort-controller "^3.0.0" + async-retry "^1.3.3" + duplexify "^4.1.3" + fast-xml-parser "^4.4.1" + gaxios "^6.0.2" + google-auth-library "^9.6.3" + html-entities "^2.5.2" + mime "^3.0.0" + p-limit "^3.0.1" + retry-request "^7.0.0" + teeny-request "^9.0.0" + uuid "^8.0.0" + "@google-cloud/vision@^4.3.2": version "4.3.2" resolved "https://registry.yarnpkg.com/@google-cloud/vision/-/vision-4.3.2.tgz#0bcc852080b9b9803b0fc943858d47c907212438" @@ -8270,6 +8304,11 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== +arrify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + asap@^2.0.0: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" @@ -10473,7 +10512,7 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -duplexify@^4.0.0, duplexify@^4.1.2: +duplexify@^4.0.0, duplexify@^4.1.2, duplexify@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.3.tgz#a07e1c0d0a2c001158563d32592ba58bddb0236f" integrity sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA== @@ -11742,7 +11781,7 @@ fast-url-parser@^1.1.3: dependencies: punycode "^1.3.2" -fast-xml-parser@4.4.1, fast-xml-parser@^4.2.2: +fast-xml-parser@4.4.1, fast-xml-parser@^4.2.2, fast-xml-parser@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" integrity sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw== @@ -12103,7 +12142,7 @@ gauge@^3.0.0: strip-ansi "^6.0.1" wide-align "^1.1.2" -gaxios@^6.0.0, gaxios@^6.1.1: +gaxios@^6.0.0, gaxios@^6.0.2, gaxios@^6.1.1: version "6.7.1" resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.7.1.tgz#ebd9f7093ede3ba502685e73390248bb5b7f71fb" integrity sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ== @@ -12418,7 +12457,7 @@ goober@^2.0.33: resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.16.tgz#7d548eb9b83ff0988d102be71f271ca8f9c82a95" integrity sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g== -google-auth-library@^9.3.0: +google-auth-library@^9.3.0, google-auth-library@^9.6.3: version "9.15.1" resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-9.15.1.tgz#0c5d84ed1890b2375f1cd74f03ac7b806b392928" integrity sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng== @@ -12866,6 +12905,11 @@ html-encoding-sniffer@^4.0.0: dependencies: whatwg-encoding "^3.1.1" +html-entities@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.5.2.tgz#201a3cf95d3a15be7099521620d19dfb4f65359f" + integrity sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA== + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -15161,6 +15205,11 @@ mime@2.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -16108,7 +16157,7 @@ p-fifo@^1.0.0: fast-fifo "^1.0.0" p-defer "^3.0.0" -p-limit@3.1.0, p-limit@^3.0.2, p-limit@^3.1.0: +p-limit@3.1.0, p-limit@^3.0.1, p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== @@ -18471,16 +18520,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18589,7 +18629,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -18603,13 +18643,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -19751,7 +19784,7 @@ uuid@9.0.1, uuid@^9.0.0, uuid@^9.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== -uuid@^8.3.2: +uuid@^8.0.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== @@ -20523,7 +20556,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -20541,15 +20574,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From dd88e4e08d155fa1561a64e031a9c5fb025e7e66 Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Wed, 12 Mar 2025 16:40:52 +0100 Subject: [PATCH 20/29] [HUMAN App] chore: move registration mutation hook (#3185) --- ...e-exchange-oracle-registration-mutation.ts} | 18 +----------------- ...stered.tsx => use-is-already-registered.ts} | 0 ...ructions.tsx => use-oracle-instructions.ts} | 0 ...low.tsx => use-oracle-registration-flow.ts} | 0 ...stration.tsx => use-oracle-registration.ts} | 6 ++---- .../oracle-registration/registration-form.tsx | 6 +++--- .../worker/oracle-registration/schema.ts | 18 ++++++++++++++++++ .../oracle-registration/sevices/index.ts | 1 - 8 files changed, 24 insertions(+), 25 deletions(-) rename packages/apps/human-app/frontend/src/modules/worker/oracle-registration/{sevices/registration-in-exchange-oracles.ts => hooks/use-exchange-oracle-registration-mutation.ts} (65%) rename packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/{use-is-already-registered.tsx => use-is-already-registered.ts} (100%) rename packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/{use-oracle-instructions.tsx => use-oracle-instructions.ts} (100%) rename packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/{use-oracle-registration-flow.tsx => use-oracle-registration-flow.ts} (100%) rename packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/{use-oracle-registration.tsx => use-oracle-registration.ts} (84%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/oracle-registration/schema.ts delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/oracle-registration/sevices/index.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/sevices/registration-in-exchange-oracles.ts b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-exchange-oracle-registration-mutation.ts similarity index 65% rename from packages/apps/human-app/frontend/src/modules/worker/oracle-registration/sevices/registration-in-exchange-oracles.ts rename to packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-exchange-oracle-registration-mutation.ts index 7406a2336c..12536bbe94 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/sevices/registration-in-exchange-oracles.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-exchange-oracle-registration-mutation.ts @@ -1,24 +1,8 @@ -/* eslint-disable camelcase */ import { z } from 'zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ethers } from 'ethers'; -import { t } from 'i18next'; import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; - -export const registrationInExchangeOracleDtoSchema = z.object({ - oracle_address: z - .string() - .refine( - (address) => ethers.isAddress(address), - t('validation.invalidOracleAddress') - ), - h_captcha_token: z.string().min(1, t('validation.captcha')).default('token'), -}); - -export type RegistrationInExchangeOracleDto = z.infer< - typeof registrationInExchangeOracleDtoSchema ->; +import { type RegistrationInExchangeOracleDto } from '../schema'; const RegistrationInExchangeOracleSuccessResponseSchema = z.unknown(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.tsx rename to packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-instructions.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-instructions.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-instructions.tsx rename to packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-instructions.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration-flow.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration-flow.ts similarity index 100% rename from packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration-flow.tsx rename to packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration-flow.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.ts similarity index 84% rename from packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.tsx rename to packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.ts index 828fa67d69..40b716d31a 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.ts @@ -1,9 +1,7 @@ import { useCallback } from 'react'; -import { - type RegistrationInExchangeOracleDto, - useExchangeOracleRegistrationMutation, -} from '@/modules/worker/oracle-registration/sevices'; import { useRegisteredOracles } from '@/shared/contexts/registered-oracles'; +import { type RegistrationInExchangeOracleDto } from '../schema'; +import { useExchangeOracleRegistrationMutation } from './use-exchange-oracle-registration-mutation'; export function useOracleRegistration(oracleAddress: string | undefined) { const { setRegisteredOracles } = useRegisteredOracles(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx index 97685e3d18..4a3e3005d6 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx @@ -4,11 +4,11 @@ import { useTranslation } from 'react-i18next'; import { zodResolver } from '@hookform/resolvers/zod'; import { Button } from '@/shared/components/ui/button'; import { HCaptchaForm } from '@/shared/components/hcaptcha'; +import { useOracleRegistrationFlow } from './hooks'; import { - registrationInExchangeOracleDtoSchema, type RegistrationInExchangeOracleDto, -} from './sevices'; -import { useOracleRegistrationFlow } from './hooks'; + registrationInExchangeOracleDtoSchema, +} from './schema'; function useRegistrationForm(address: string) { return useForm({ diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/schema.ts b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/schema.ts new file mode 100644 index 0000000000..a87e92b0d5 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/schema.ts @@ -0,0 +1,18 @@ +/* eslint-disable camelcase */ +import { ethers } from 'ethers'; +import { t } from 'i18next'; +import { z } from 'zod'; + +export const registrationInExchangeOracleDtoSchema = z.object({ + oracle_address: z + .string() + .refine( + (address) => ethers.isAddress(address), + t('validation.invalidOracleAddress') + ), + h_captcha_token: z.string().min(1, t('validation.captcha')).default('token'), +}); + +export type RegistrationInExchangeOracleDto = z.infer< + typeof registrationInExchangeOracleDtoSchema +>; diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/sevices/index.ts b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/sevices/index.ts deleted file mode 100644 index aeaed03788..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/sevices/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './registration-in-exchange-oracles'; From 2d3adb68266d4b0eaaeff7e8ecb596a5ee0be51d Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Thu, 13 Mar 2025 09:57:32 +0100 Subject: [PATCH 21/29] [HUMAN App] refactor: service sign up (#3184) --- .../frontend/src/api/fetch-refresh-token.ts | 4 +-- .../human-app/frontend/src/api/fetcher.ts | 4 +-- .../auth-web3/context/web3-auth-context.tsx | 8 ++--- .../src/modules/auth/context/auth-context.tsx | 8 ++--- .../schema.ts => signin/worker/schemas.ts} | 7 +---- .../modules/signin/worker/sign-in-form.tsx | 3 +- .../worker/use-sign-in-mutation.ts} | 6 ++-- .../src/modules/signin/worker/use-sign-in.tsx | 4 +-- .../worker/hooks/use-sign-up-mutation.ts} | 31 +------------------ .../worker/hooks/use-sign-up-worker.tsx | 4 +-- .../src/modules/signup/worker/schema.ts | 28 +++++++++++++++++ .../worker/views/sign-up-worker.page.tsx | 3 +- .../modules/worker/services/sign-in/types.ts | 9 ------ .../shared/contexts/browser-auth-provider.ts | 4 +-- .../src/shared/schemas/auth-tokens-schema.ts | 12 +++++++ .../frontend/src/shared/schemas/index.ts | 1 + .../src/shared/types/browser-auth-provider.ts | 4 +-- 17 files changed, 68 insertions(+), 72 deletions(-) rename packages/apps/human-app/frontend/src/modules/{worker/services/sign-in/schema.ts => signin/worker/schemas.ts} (64%) rename packages/apps/human-app/frontend/src/modules/{worker/services/sign-in/sign-in.ts => signin/worker/use-sign-in-mutation.ts} (85%) rename packages/apps/human-app/frontend/src/modules/{worker/services/sign-up.ts => signup/worker/hooks/use-sign-up-mutation.ts} (54%) create mode 100644 packages/apps/human-app/frontend/src/modules/signup/worker/schema.ts delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/sign-in/types.ts create mode 100644 packages/apps/human-app/frontend/src/shared/schemas/auth-tokens-schema.ts diff --git a/packages/apps/human-app/frontend/src/api/fetch-refresh-token.ts b/packages/apps/human-app/frontend/src/api/fetch-refresh-token.ts index 77226af471..2f99243801 100644 --- a/packages/apps/human-app/frontend/src/api/fetch-refresh-token.ts +++ b/packages/apps/human-app/frontend/src/api/fetch-refresh-token.ts @@ -1,6 +1,6 @@ import { apiPaths } from '@/api/api-paths'; import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; -import { signInSuccessResponseSchema } from '@/modules/worker/services/sign-in/schema'; +import { authTokensSuccessResponseSchema } from '@/shared/schemas'; export const fetchTokenRefresh = async (baseUrl: string) => { const response = await fetch( @@ -23,7 +23,7 @@ export const fetchTokenRefresh = async (baseUrl: string) => { const data: unknown = await response.json(); - const refetchAccessTokenSuccess = signInSuccessResponseSchema.parse(data); + const refetchAccessTokenSuccess = authTokensSuccessResponseSchema.parse(data); return refetchAccessTokenSuccess; }; diff --git a/packages/apps/human-app/frontend/src/api/fetcher.ts b/packages/apps/human-app/frontend/src/api/fetcher.ts index d91945c258..275a9932e3 100644 --- a/packages/apps/human-app/frontend/src/api/fetcher.ts +++ b/packages/apps/human-app/frontend/src/api/fetcher.ts @@ -3,8 +3,8 @@ import { ZodError, type ZodType, type ZodTypeDef } from 'zod'; import type { ResponseError } from '@/shared/types/global.type'; import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; import { env } from '@/shared/env'; -import { type SignInSuccessResponse } from '@/modules/worker/services/sign-in/types'; import { normalizeBaseUrl } from '@/shared/helpers/url'; +import { type AuthTokensSuccessResponse } from '@/shared/schemas'; import { fetchTokenRefresh } from './fetch-refresh-token'; const appendHeader = ( @@ -66,7 +66,7 @@ export type FetcherOptions = export type FetcherUrl = string | URL; -let refreshPromise: Promise | null = null; +let refreshPromise: Promise | null = null; export async function refreshToken(): Promise<{ access_token: string; diff --git a/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx index 4f1f1f2f6c..a3332c8a09 100644 --- a/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx @@ -3,12 +3,12 @@ import { useState, createContext, useEffect } from 'react'; import { jwtDecode } from 'jwt-decode'; import { z } from 'zod'; import { useQueryClient } from '@tanstack/react-query'; -import type { SignInSuccessResponse } from '@/modules/worker/services/sign-in/types'; import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; import { ModalType, useModalStore, } from '@/shared/components/ui/modal/modal.store'; +import { type AuthTokensSuccessResponse } from '@/shared/schemas'; const web3userDataSchema = z.object({ userId: z.number(), @@ -25,7 +25,7 @@ export interface Web3AuthenticatedUserContextType { user: Web3UserData; status: AuthStatus; signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: SignInSuccessResponse) => void; + signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; updateUserData: (updateUserDataPayload: Partial) => void; } @@ -33,7 +33,7 @@ interface Web3UnauthenticatedUserContextType { user: null; status: AuthStatus; signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: SignInSuccessResponse) => void; + signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; } export const Web3AuthContext = createContext< @@ -97,7 +97,7 @@ export function Web3AuthProvider({ children }: { children: React.ReactNode }) { } }; - const signIn = (singIsSuccess: SignInSuccessResponse) => { + const signIn = (singIsSuccess: AuthTokensSuccessResponse) => { browserAuthProvider.signIn(singIsSuccess, 'web3'); handleSignIn(); }; diff --git a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx index b98a797e8b..ed300db26e 100644 --- a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx @@ -3,12 +3,12 @@ import { useState, createContext, useEffect } from 'react'; import { jwtDecode } from 'jwt-decode'; import { z } from 'zod'; import { useQueryClient } from '@tanstack/react-query'; -import type { SignInSuccessResponse } from '@/modules/worker/services/sign-in/types'; import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; import { ModalType, useModalStore, } from '@/shared/components/ui/modal/modal.store'; +import { type AuthTokensSuccessResponse } from '@/shared/schemas'; const extendableUserDataSchema = z.object({ site_key: z.string().optional().nullable(), @@ -35,7 +35,7 @@ export interface AuthenticatedUserContextType { user: UserData; status: AuthStatus; signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: SignInSuccessResponse) => void; + signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; updateUserData: (updateUserDataPayload: UpdateUserDataPayload) => void; } @@ -43,7 +43,7 @@ interface UnauthenticatedUserContextType { user: null; status: AuthStatus; signOut: (throwExpirationModal?: boolean) => void; - signIn: (singIsSuccess: SignInSuccessResponse) => void; + signIn: (singIsSuccess: AuthTokensSuccessResponse) => void; } export const AuthContext = createContext< @@ -112,7 +112,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }; - const signIn = (singIsSuccess: SignInSuccessResponse) => { + const signIn = (singIsSuccess: AuthTokensSuccessResponse) => { browserAuthProvider.signIn(singIsSuccess, 'web2'); handleSignIn(); }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/sign-in/schema.ts b/packages/apps/human-app/frontend/src/modules/signin/worker/schemas.ts similarity index 64% rename from packages/apps/human-app/frontend/src/modules/worker/services/sign-in/schema.ts rename to packages/apps/human-app/frontend/src/modules/signin/worker/schemas.ts index 2002a3a5e0..65903954a7 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/services/sign-in/schema.ts +++ b/packages/apps/human-app/frontend/src/modules/signin/worker/schemas.ts @@ -11,9 +11,4 @@ export const signInDtoSchema = z.object({ h_captcha_token: z.string().min(1, t('validation.captcha')).default('token'), }); -export const signInSuccessResponseSchema = z.object({ - // eslint-disable-next-line camelcase -- data from api - access_token: z.string(), - // eslint-disable-next-line camelcase -- data from api - refresh_token: z.string(), -}); +export type SignInDto = z.infer; diff --git a/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx b/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx index ca6e34ecf8..f4e11c0811 100644 --- a/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/signin/worker/sign-in-form.tsx @@ -8,10 +8,9 @@ import { Input } from '@/shared/components/data-entry/input'; import { Button } from '@/shared/components/ui/button'; import { Password } from '@/shared/components/data-entry/password/password'; import { routerPaths } from '@/router/router-paths'; -import { type SignInDto } from '@/modules/worker/services/sign-in/types'; -import { signInDtoSchema } from '@/modules/worker/services/sign-in/schema'; import { useResetMutationErrors } from '@/shared/hooks/use-reset-mutation-errors'; import { HCaptchaForm } from '@/shared/components/hcaptcha'; +import { type SignInDto, signInDtoSchema } from './schemas'; interface SignInFormProps { onSubmit: (data: SignInDto) => void; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/sign-in/sign-in.ts b/packages/apps/human-app/frontend/src/modules/signin/worker/use-sign-in-mutation.ts similarity index 85% rename from packages/apps/human-app/frontend/src/modules/worker/services/sign-in/sign-in.ts rename to packages/apps/human-app/frontend/src/modules/signin/worker/use-sign-in-mutation.ts index 2cfd14d324..2b54f8e727 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/services/sign-in/sign-in.ts +++ b/packages/apps/human-app/frontend/src/modules/signin/worker/use-sign-in-mutation.ts @@ -4,12 +4,12 @@ import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; import { routerPaths } from '@/router/router-paths'; import { useAuth } from '@/modules/auth/hooks/use-auth'; -import { type SignInDto } from './types'; -import { signInSuccessResponseSchema } from './schema'; +import { authTokensSuccessResponseSchema } from '@/shared/schemas'; +import { type SignInDto } from './schemas'; function signInMutationFn(data: SignInDto) { return apiClient(apiPaths.worker.signIn.path, { - successSchema: signInSuccessResponseSchema, + successSchema: authTokensSuccessResponseSchema, options: { method: 'POST', body: JSON.stringify(data), diff --git a/packages/apps/human-app/frontend/src/modules/signin/worker/use-sign-in.tsx b/packages/apps/human-app/frontend/src/modules/signin/worker/use-sign-in.tsx index 29121d5791..9368785558 100644 --- a/packages/apps/human-app/frontend/src/modules/signin/worker/use-sign-in.tsx +++ b/packages/apps/human-app/frontend/src/modules/signin/worker/use-sign-in.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; -import { useSignInMutation } from '@/modules/worker/services/sign-in/sign-in'; import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; -import { type SignInDto } from '@/modules/worker/services/sign-in/types'; +import { useSignInMutation } from './use-sign-in-mutation'; +import { type SignInDto } from './schemas'; export function useSignIn() { const { diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/sign-up.ts b/packages/apps/human-app/frontend/src/modules/signup/worker/hooks/use-sign-up-mutation.ts similarity index 54% rename from packages/apps/human-app/frontend/src/modules/worker/services/sign-up.ts rename to packages/apps/human-app/frontend/src/modules/signup/worker/hooks/use-sign-up-mutation.ts index 3448c1b729..325b951b1e 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/services/sign-up.ts +++ b/packages/apps/human-app/frontend/src/modules/signup/worker/hooks/use-sign-up-mutation.ts @@ -1,39 +1,10 @@ import { z } from 'zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { t } from 'i18next'; import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; import { routerPaths } from '@/router/router-paths'; - -export const signUpDtoSchema = z - .object({ - email: z.string().email(t('validation.invalidEmail')), - // eslint-disable-next-line camelcase -- export vite config - h_captcha_token: z - .string() - .min(1, t('validation.captcha')) - .default('token'), - }) - .and( - z - .object({ - password: z - .string() - .min(8, t('validation.min')) - .max(50, t('validation.max', { count: 50 })), - confirmPassword: z - .string() - .min(1, t('validation.required')) - .max(50, t('validation.max', { count: 50 })), - }) - .refine(({ password, confirmPassword }) => confirmPassword === password, { - message: t('validation.passwordMismatch'), - path: ['confirmPassword'], - }) - ); - -export type SignUpDto = z.infer; +import { type SignUpDto } from '../schema'; const signUpSuccessResponseSchema = z.unknown(); diff --git a/packages/apps/human-app/frontend/src/modules/signup/worker/hooks/use-sign-up-worker.tsx b/packages/apps/human-app/frontend/src/modules/signup/worker/hooks/use-sign-up-worker.tsx index 6dc975e461..bef557d9d1 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/worker/hooks/use-sign-up-worker.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/worker/hooks/use-sign-up-worker.tsx @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import omit from 'lodash/omit'; import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; -import type { SignUpDto } from '@/modules/worker/services/sign-up'; -import { useSignUpMutation } from '@/modules/worker/services/sign-up'; +import { type SignUpDto } from '../schema'; +import { useSignUpMutation } from './use-sign-up-mutation'; export function useSignUpWorker() { const { diff --git a/packages/apps/human-app/frontend/src/modules/signup/worker/schema.ts b/packages/apps/human-app/frontend/src/modules/signup/worker/schema.ts new file mode 100644 index 0000000000..1aa96aa028 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/signup/worker/schema.ts @@ -0,0 +1,28 @@ +import { t } from 'i18next'; +import { z } from 'zod'; + +export const signUpDtoSchema = z + .object({ + email: z.string().email(t('validation.invalidEmail')), + // eslint-disable-next-line camelcase -- export vite config + h_captcha_token: z + .string() + .min(1, t('validation.captcha')) + .default('token'), + }) + .and( + z + .object({ + password: z + .string() + .min(8, t('validation.min')) + .max(50, t('validation.max', { count: 50 })), + confirmPassword: z.string().min(1, t('validation.required')), + }) + .refine(({ password, confirmPassword }) => confirmPassword === password, { + message: t('validation.passwordMismatch'), + path: ['confirmPassword'], + }) + ); + +export type SignUpDto = z.infer; diff --git a/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx b/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx index a52a2409e9..06f9696137 100644 --- a/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/signup/worker/views/sign-up-worker.page.tsx @@ -4,8 +4,6 @@ import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import Link from '@mui/material/Link'; import { useTranslation, Trans } from 'react-i18next'; -import type { SignUpDto } from '@/modules/worker/services/sign-up'; -import { signUpDtoSchema } from '@/modules/worker/services/sign-up'; import { Button } from '@/shared/components/ui/button'; import { Input } from '@/shared/components/data-entry/input'; import { Password } from '@/shared/components/data-entry/password/password'; @@ -17,6 +15,7 @@ import { HCaptchaForm } from '@/shared/components/hcaptcha'; import { useResetMutationErrors } from '@/shared/hooks/use-reset-mutation-errors'; import { FetchError } from '@/api/fetcher'; import { useSignUpWorker } from '@/modules/signup/worker/hooks/use-sign-up-worker'; +import { signUpDtoSchema, type SignUpDto } from '../schema'; export function SignUpWorkerPage() { const { t } = useTranslation(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/sign-in/types.ts b/packages/apps/human-app/frontend/src/modules/worker/services/sign-in/types.ts deleted file mode 100644 index 6f5dbeae11..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/services/sign-in/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type z } from 'zod'; -import { - type signInDtoSchema, - type signInSuccessResponseSchema, -} from './schema'; - -export type SignInDto = z.infer; - -export type SignInSuccessResponse = z.infer; diff --git a/packages/apps/human-app/frontend/src/shared/contexts/browser-auth-provider.ts b/packages/apps/human-app/frontend/src/shared/contexts/browser-auth-provider.ts index 2e559e097c..ce781b4439 100644 --- a/packages/apps/human-app/frontend/src/shared/contexts/browser-auth-provider.ts +++ b/packages/apps/human-app/frontend/src/shared/contexts/browser-auth-provider.ts @@ -1,6 +1,6 @@ /* eslint-disable camelcase -- ...*/ -import { type SignInSuccessResponse } from '@/modules/worker/services/sign-in/types'; import type { BrowserAuthProvider } from '@/shared/types/browser-auth-provider'; +import { type AuthTokensSuccessResponse } from '../schemas'; const accessTokenKey = btoa('access_token'); const refreshTokenKey = btoa('refresh_token'); @@ -11,7 +11,7 @@ const browserAuthProvider: BrowserAuthProvider = { isAuthenticated: false, authType: 'web2', signIn( - { access_token, refresh_token }: SignInSuccessResponse, + { access_token, refresh_token }: AuthTokensSuccessResponse, authType, signOutSubscription ) { diff --git a/packages/apps/human-app/frontend/src/shared/schemas/auth-tokens-schema.ts b/packages/apps/human-app/frontend/src/shared/schemas/auth-tokens-schema.ts new file mode 100644 index 0000000000..3cb24a0b99 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/schemas/auth-tokens-schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const authTokensSuccessResponseSchema = z.object({ + // eslint-disable-next-line camelcase -- data from api + access_token: z.string(), + // eslint-disable-next-line camelcase -- data from api + refresh_token: z.string(), +}); + +export type AuthTokensSuccessResponse = z.infer< + typeof authTokensSuccessResponseSchema +>; diff --git a/packages/apps/human-app/frontend/src/shared/schemas/index.ts b/packages/apps/human-app/frontend/src/shared/schemas/index.ts index adde22820b..73ec748501 100644 --- a/packages/apps/human-app/frontend/src/shared/schemas/index.ts +++ b/packages/apps/human-app/frontend/src/shared/schemas/index.ts @@ -1,2 +1,3 @@ export * from './validate-address-schema'; export * from './url-domain-schema'; +export * from './auth-tokens-schema'; diff --git a/packages/apps/human-app/frontend/src/shared/types/browser-auth-provider.ts b/packages/apps/human-app/frontend/src/shared/types/browser-auth-provider.ts index 61413cfe5d..297669b6c9 100644 --- a/packages/apps/human-app/frontend/src/shared/types/browser-auth-provider.ts +++ b/packages/apps/human-app/frontend/src/shared/types/browser-auth-provider.ts @@ -1,6 +1,6 @@ -import type { SignInSuccessResponse } from '@/modules/worker/services/sign-in/types'; import type { Web3UserData } from '@/modules/auth-web3/context/web3-auth-context'; import type { UserData } from '@/modules/auth/context/auth-context'; +import { type AuthTokensSuccessResponse } from '../schemas'; export type AuthType = 'web2' | 'web3'; type SubscriptionCallback = () => void; @@ -8,7 +8,7 @@ export interface BrowserAuthProvider { isAuthenticated: boolean; authType: AuthType; signIn: ( - singInSuccessData: SignInSuccessResponse, + singInSuccessData: AuthTokensSuccessResponse, authType: AuthType, signOutSubscription?: SubscriptionCallback ) => void; From 2e9471ffa8e9f025a16383718184dfe57f217813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:18:56 +0100 Subject: [PATCH 22/29] remove token addresses in constants (#3192) --- .../server/src/common/constants/tokens.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/apps/job-launcher/server/src/common/constants/tokens.ts b/packages/apps/job-launcher/server/src/common/constants/tokens.ts index 8c0c51a7f7..103c7768ad 100644 --- a/packages/apps/job-launcher/server/src/common/constants/tokens.ts +++ b/packages/apps/job-launcher/server/src/common/constants/tokens.ts @@ -8,30 +8,30 @@ export const TOKEN_ADDRESSES: { } = { [ChainId.MAINNET]: { [EscrowFundToken.HMT]: NETWORKS[ChainId.MAINNET]?.hmtAddress, - [EscrowFundToken.USDT]: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - [EscrowFundToken.USDC]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606EB48', + // [EscrowFundToken.USDT]: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + // [EscrowFundToken.USDC]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606EB48', }, [ChainId.SEPOLIA]: { [EscrowFundToken.HMT]: NETWORKS[ChainId.SEPOLIA]?.hmtAddress, - [EscrowFundToken.USDT]: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', - [EscrowFundToken.USDC]: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + // [EscrowFundToken.USDT]: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + // [EscrowFundToken.USDC]: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', }, [ChainId.BSC_MAINNET]: { [EscrowFundToken.HMT]: NETWORKS[ChainId.BSC_MAINNET]?.hmtAddress, - [EscrowFundToken.USDT]: '0x55d398326f99059fF775485246999027B3197955', - [EscrowFundToken.USDC]: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + // [EscrowFundToken.USDT]: '0x55d398326f99059fF775485246999027B3197955', + // [EscrowFundToken.USDC]: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', }, [ChainId.POLYGON]: { [EscrowFundToken.HMT]: NETWORKS[ChainId.POLYGON]?.hmtAddress, - [EscrowFundToken.USDT]: '0x3813e82e6f7098b9583FC0F33a962D02018B6803', - [EscrowFundToken.USDC]: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + // [EscrowFundToken.USDT]: '0x3813e82e6f7098b9583FC0F33a962D02018B6803', + // [EscrowFundToken.USDC]: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', }, [ChainId.POLYGON_AMOY]: { [EscrowFundToken.HMT]: NETWORKS[ChainId.POLYGON_AMOY]?.hmtAddress, - [EscrowFundToken.USDC]: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', + // [EscrowFundToken.USDC]: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', }, [ChainId.LOCALHOST]: { [EscrowFundToken.HMT]: NETWORKS[ChainId.LOCALHOST]?.hmtAddress, - [EscrowFundToken.USDC]: '0x09635F643e140090A9A8Dcd712eD6285858ceBef', + // [EscrowFundToken.USDC]: '0x09635F643e140090A9A8Dcd712eD6285858ceBef', }, }; From 7eb135874609b3eccc51dc6cd17597bcbd80a85d Mon Sep 17 00:00:00 2001 From: Siarhei Date: Fri, 14 Mar 2025 11:49:04 +0300 Subject: [PATCH 23/29] [Reputation Oracle] `NDA` module refactoring (#3188) --- .../server/src/config/auth-config.service.ts | 7 --- .../server/src/config/config.module.ts | 33 +++++----- .../server/src/config/env-schema.ts | 4 +- .../server/src/config/nda-config.service.ts | 14 +++++ .../src/modules/auth/auth.service.spec.ts | 6 ++ .../server/src/modules/auth/auth.service.ts | 4 +- .../server/src/modules/nda/nda.controller.ts | 18 +++--- .../src/modules/nda/nda.error.filter.ts | 2 +- .../server/src/modules/nda/nda.error.ts | 1 - .../server/src/modules/nda/nda.module.ts | 3 +- .../src/modules/nda/nda.service.spec.ts | 61 +++++++++++-------- .../server/src/modules/nda/nda.service.ts | 11 ++-- 12 files changed, 99 insertions(+), 65 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/config/nda-config.service.ts diff --git a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts index 2a52f577dc..81c24aa0e1 100644 --- a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts @@ -68,11 +68,4 @@ export class AuthConfigService { get humanAppEmail(): string { return this.configService.getOrThrow('HUMAN_APP_EMAIL'); } - - /** - * Latest NDA Url. - */ - get latestNdaUrl(): string { - return this.configService.getOrThrow('NDA_URL'); - } } diff --git a/packages/apps/reputation-oracle/server/src/config/config.module.ts b/packages/apps/reputation-oracle/server/src/config/config.module.ts index ed148bc9f8..dbccf1bdc3 100644 --- a/packages/apps/reputation-oracle/server/src/config/config.module.ts +++ b/packages/apps/reputation-oracle/server/src/config/config.module.ts @@ -2,42 +2,45 @@ import { Module, Global } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AuthConfigService } from './auth-config.service'; -import { ServerConfigService } from './server-config.service'; import { DatabaseConfigService } from './database-config.service'; +import { EmailConfigService } from './email-config.service'; +import { HCaptchaConfigService } from './hcaptcha-config.service'; +import { KycConfigService } from './kyc-config.service'; +import { NDAConfigService } from './nda-config.service'; import { PGPConfigService } from './pgp-config.service'; +import { ReputationConfigService } from './reputation-config.service'; import { S3ConfigService } from './s3-config.service'; -import { EmailConfigService } from './email-config.service'; +import { ServerConfigService } from './server-config.service'; import { Web3ConfigService } from './web3-config.service'; -import { ReputationConfigService } from './reputation-config.service'; -import { KycConfigService } from './kyc-config.service'; -import { HCaptchaConfigService } from './hcaptcha-config.service'; @Global() @Module({ imports: [ConfigModule], providers: [ - ServerConfigService, AuthConfigService, DatabaseConfigService, - Web3ConfigService, - S3ConfigService, - ReputationConfigService, EmailConfigService, + HCaptchaConfigService, KycConfigService, + NDAConfigService, PGPConfigService, - HCaptchaConfigService, + ReputationConfigService, + S3ConfigService, + ServerConfigService, + Web3ConfigService, ], exports: [ - ServerConfigService, AuthConfigService, DatabaseConfigService, - Web3ConfigService, - S3ConfigService, - ReputationConfigService, EmailConfigService, + HCaptchaConfigService, KycConfigService, + NDAConfigService, PGPConfigService, - HCaptchaConfigService, + ReputationConfigService, + S3ConfigService, + ServerConfigService, + Web3ConfigService, ], }) export class EnvConfigModule {} diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index 6ca802221a..b412bf9d6a 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -8,7 +8,9 @@ export const envValidator = Joi.object({ FE_URL: Joi.string(), MAX_RETRY_COUNT: Joi.number(), QUALIFICATION_MIN_VALIDITY: Joi.number(), - NDA_URL: Joi.string().required(), + NDA_URL: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .required(), // Auth JWT_PRIVATE_KEY: Joi.string().required(), JWT_PUBLIC_KEY: Joi.string().required(), diff --git a/packages/apps/reputation-oracle/server/src/config/nda-config.service.ts b/packages/apps/reputation-oracle/server/src/config/nda-config.service.ts new file mode 100644 index 0000000000..391c38d574 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/nda-config.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class NDAConfigService { + constructor(private readonly configService: ConfigService) {} + + /** + * Latest NDA Url. + */ + get latestNdaUrl(): string { + return this.configService.getOrThrow('NDA_URL'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index aed9ffae08..5b889f71d7 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -23,6 +23,7 @@ import { mockConfig, } from '../../../test/constants'; import { AuthConfigService } from '../../config/auth-config.service'; +import { NDAConfigService } from '../../config/nda-config.service'; import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; import { ServerConfigService } from '../../config/server-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; @@ -75,6 +76,10 @@ jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('mocked-uuid'), })); +const mockNdaConfigService = { + latestNdaUrl: faker.internet.url(), +}; + describe('AuthService', () => { let authService: AuthService; let tokenRepository: TokenRepository; @@ -108,6 +113,7 @@ describe('AuthService', () => { AuthService, UserService, AuthConfigService, + { provide: NDAConfigService, useValue: mockNdaConfigService }, ServerConfigService, Web3ConfigService, HCaptchaService, diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index 52a854e843..fd15becc9c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -22,6 +22,7 @@ import { Web3Service } from '../web3/web3.service'; import { SignatureType } from '../../common/enums/web3'; import { prepareSignatureBody } from '../../utils/web3'; import { AuthConfigService } from '../../config/auth-config.service'; +import { NDAConfigService } from '../../config/nda-config.service'; import { ServerConfigService } from '../../config/server-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; import { SiteKeyType } from '../../common/enums'; @@ -61,6 +62,7 @@ export class AuthService { private readonly tokenRepository: TokenRepository, private readonly serverConfigService: ServerConfigService, private readonly authConfigService: AuthConfigService, + private readonly ndaConfigService: NDAConfigService, private readonly web3ConfigService: Web3ConfigService, private readonly emailService: EmailService, private readonly web3Service: Web3Service, @@ -156,7 +158,7 @@ export class AuthService { role: userEntity.role, kyc_status: userEntity.kyc?.status, nda_signed: - userEntity.ndaSignedUrl === this.authConfigService.latestNdaUrl, + userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, reputation_network: operatorAddress, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts index 3c63ae4520..8d7e6534c9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.controller.ts @@ -1,13 +1,12 @@ import { + Body, Controller, Get, + HttpCode, Post, - Body, Req, UseFilters, - HttpCode, } from '@nestjs/common'; -import { NDAService } from './nda.service'; import { ApiBearerAuth, ApiBody, @@ -15,10 +14,13 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { RequestWithUser } from 'src/common/interfaces/request'; -import { NDAErrorFilter } from './nda.error.filter'; -import { AuthConfigService } from 'src/config/auth-config.service'; + +import { RequestWithUser } from '../../common/interfaces/request'; +import { NDAConfigService } from '../../config/nda-config.service'; + import { NDASignatureDto } from './nda.dto'; +import { NDAErrorFilter } from './nda.error.filter'; +import { NDAService } from './nda.service'; @ApiTags('NDA') @ApiBearerAuth() @@ -27,7 +29,7 @@ import { NDASignatureDto } from './nda.dto'; export class NDAController { constructor( private readonly ndaService: NDAService, - private readonly authConfigService: AuthConfigService, + private readonly ndaConfigService: NDAConfigService, ) {} @ApiOperation({ @@ -42,7 +44,7 @@ export class NDAController { }) @Get('latest') getLatestNDA() { - return this.authConfigService.latestNdaUrl; + return this.ndaConfigService.latestNdaUrl; } @ApiOperation({ diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts index d6ce368f16..100e065481 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.filter.ts @@ -6,8 +6,8 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; -import { NDAError } from './nda.error'; import logger from '../../logger'; +import { NDAError } from './nda.error'; @Catch(NDAError) export class NDAErrorFilter implements ExceptionFilter { diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts index d253c6f8c8..386312829c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.error.ts @@ -2,7 +2,6 @@ import { BaseError } from '../../common/errors/base'; export enum NDAErrorMessage { INVALID_NDA = 'Invalid NDA URL', - NDA_EXISTS = 'User has already signed the NDA', } export class NDAError extends BaseError { diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts index e9a0936013..ce7b4973f8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; + +import { UserModule } from '../user'; import { NDAController } from './nda.controller'; import { NDAService } from './nda.service'; -import { UserModule } from '../user'; @Module({ imports: [UserModule], diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts index f25a8d33f6..f01a8edd78 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts @@ -1,18 +1,17 @@ +import { faker } from '@faker-js/faker/.'; import { Test, TestingModule } from '@nestjs/testing'; -import { NDAService } from './nda.service'; -import { UserRepository } from '../user/user.repository'; -import { AuthConfigService } from '../../config/auth-config.service'; + +import { UserEntity, UserRepository } from '../user'; +import { NDAConfigService } from '../../config/nda-config.service'; import { NDASignatureDto } from './nda.dto'; import { NDAError, NDAErrorMessage } from './nda.error'; -import { faker } from '@faker-js/faker/.'; -import { UserEntity } from '../user/user.entity'; +import { NDAService } from './nda.service'; const mockUserRepository = { updateOne: jest.fn(), }; -const validNdaUrl = faker.internet.url(); -const mockAuthConfigService = { - latestNdaUrl: validNdaUrl, +const mockNdaConfigService = { + latestNdaUrl: faker.internet.url(), }; describe('NDAService', () => { @@ -23,7 +22,7 @@ describe('NDAService', () => { providers: [ NDAService, { provide: UserRepository, useValue: mockUserRepository }, - { provide: AuthConfigService, useValue: mockAuthConfigService }, + { provide: NDAConfigService, useValue: mockNdaConfigService }, ], }).compile(); @@ -35,40 +34,52 @@ describe('NDAService', () => { }); describe('signNDA', () => { - let user: Pick; - beforeEach(() => { - user = { + it('should sign the NDA if the URL is valid and the user has not signed it yet', async () => { + const user = { id: faker.number.int(), email: faker.internet.email(), ndaSignedUrl: undefined, }; - }); - const nda: NDASignatureDto = { - url: validNdaUrl, - }; - it('should sign the NDA if the URL is valid and the user has not signed it yet', async () => { - await service.signNDA(user as any, nda); + const nda: NDASignatureDto = { + url: mockNdaConfigService.latestNdaUrl, + }; - expect(user.ndaSignedUrl).toBe(validNdaUrl); + await service.signNDA(user as UserEntity, nda); + + expect(user.ndaSignedUrl).toBe(mockNdaConfigService.latestNdaUrl); expect(mockUserRepository.updateOne).toHaveBeenCalledWith(user); }); it('should throw an error if the NDA URL is invalid', async () => { + const user = { + id: faker.number.int(), + email: faker.internet.email(), + ndaSignedUrl: undefined, + }; + const invalidNda: NDASignatureDto = { url: faker.internet.url(), }; - await expect(service.signNDA(user as any, invalidNda)).rejects.toThrow( - new NDAError(NDAErrorMessage.INVALID_NDA, user.id), - ); + await expect( + service.signNDA(user as UserEntity, invalidNda), + ).rejects.toThrow(new NDAError(NDAErrorMessage.INVALID_NDA, user.id)); }); it('should return ok if the user has already signed the NDA', async () => { - user.ndaSignedUrl = mockAuthConfigService.latestNdaUrl; - await service.signNDA(user as any, nda); + const user = { + id: faker.number.int(), + email: faker.internet.email(), + ndaSignedUrl: mockNdaConfigService.latestNdaUrl, + }; + + const nda: NDASignatureDto = { + url: mockNdaConfigService.latestNdaUrl, + }; + + await service.signNDA(user as UserEntity, nda); - expect(user.ndaSignedUrl).toBe(validNdaUrl); expect(mockUserRepository.updateOne).not.toHaveBeenCalled(); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts index e22543d5cd..0fbbbe7c3d 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { AuthConfigService } from '../../config/auth-config.service'; + import { UserEntity, UserRepository } from '../user'; +import { NDAConfigService } from '../../config/nda-config.service'; import { NDASignatureDto } from './nda.dto'; import { NDAError, NDAErrorMessage } from './nda.error'; @@ -8,15 +9,15 @@ import { NDAError, NDAErrorMessage } from './nda.error'; export class NDAService { constructor( private readonly userRepository: UserRepository, - private readonly authConfigService: AuthConfigService, + private readonly ndaConfigService: NDAConfigService, ) {} async signNDA(user: UserEntity, nda: NDASignatureDto) { - const ndaUrl = this.authConfigService.latestNdaUrl; - if (nda.url !== ndaUrl) { + const latestNdaUrl = this.ndaConfigService.latestNdaUrl; + if (nda.url !== latestNdaUrl) { throw new NDAError(NDAErrorMessage.INVALID_NDA, user.id); } - if (user.ndaSignedUrl === ndaUrl) { + if (user.ndaSignedUrl === latestNdaUrl) { return; } From 173f8f0ab7ff06cdef1b0f110b709a2a52c7b823 Mon Sep 17 00:00:00 2001 From: Siarhei Date: Mon, 17 Mar 2025 15:30:44 +0300 Subject: [PATCH 24/29] [Reputation Oracle] refactor: added rules to the `Joi` env validation (#3194) * refactor: added some rules to the `Joi` env validation * refactor: joi validation schema * fix: typo --- .../server/src/config/env-schema.ts | 63 ++++++++++--------- .../server/src/config/pgp-config.service.ts | 2 +- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index b412bf9d6a..d3aeb4682a 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -4,27 +4,32 @@ import { Web3Network } from './web3-config.service'; export const envValidator = Joi.object({ // General HOST: Joi.string(), - PORT: Joi.string(), + PORT: Joi.number().integer(), FE_URL: Joi.string(), - MAX_RETRY_COUNT: Joi.number(), - QUALIFICATION_MIN_VALIDITY: Joi.number(), + MAX_RETRY_COUNT: Joi.number().integer().min(0), + QUALIFICATION_MIN_VALIDITY: Joi.number() + .integer() + .min(1) + .description('Minimum qualification validity period (in days)'), NDA_URL: Joi.string() .uri({ scheme: ['http', 'https'] }) .required(), // Auth JWT_PRIVATE_KEY: Joi.string().required(), JWT_PUBLIC_KEY: Joi.string().required(), - JWT_ACCESS_TOKEN_EXPIRES_IN: Joi.number(), - JWT_REFRESH_TOKEN_EXPIRES_IN: Joi.number(), - VERIFY_EMAIL_TOKEN_EXPIRES_IN: Joi.number(), - FORGOT_PASSWORD_TOKEN_EXPIRES_IN: Joi.number(), + JWT_ACCESS_TOKEN_EXPIRES_IN: Joi.number().integer().min(1), + JWT_REFRESH_TOKEN_EXPIRES_IN: Joi.number().integer().min(1), + VERIFY_EMAIL_TOKEN_EXPIRES_IN: Joi.number().integer().min(1), + FORGOT_PASSWORD_TOKEN_EXPIRES_IN: Joi.number().integer().min(1), // hCaptcha HCAPTCHA_SITE_KEY: Joi.string().required(), HCAPTCHA_SECRET: Joi.string().required(), - HCAPTCHA_PROTECTION_URL: Joi.string().description( - 'Hcaptcha URL for verifying guard tokens', - ), - HCAPTCHA_LABELING_URL: Joi.string().description('hcaptcha labeling url'), + HCAPTCHA_PROTECTION_URL: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .description('Hcaptcha URL for verifying guard tokens'), + HCAPTCHA_LABELING_URL: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .description('Hcaptcha labeling url'), HCAPTCHA_API_KEY: Joi.string() .required() .description('Account api key at hcaptcha foundation'), @@ -34,45 +39,45 @@ export const envValidator = Joi.object({ POSTGRES_USER: Joi.string(), POSTGRES_PASSWORD: Joi.string(), POSTGRES_DATABASE: Joi.string(), - POSTGRES_PORT: Joi.string(), - POSTGRES_SSL: Joi.string(), + POSTGRES_PORT: Joi.number().integer(), + POSTGRES_SSL: Joi.string().valid('true', 'false'), POSTGRES_URL: Joi.string(), POSTGRES_LOGGING: Joi.string(), // Web3 WEB3_ENV: Joi.string().valid(...Object.values(Web3Network)), WEB3_PRIVATE_KEY: Joi.string().required(), - GAS_PRICE_MULTIPLIER: Joi.number(), - RPC_URL_SEPOLIA: Joi.string(), - RPC_URL_POLYGON: Joi.string(), - RPC_URL_POLYGON_AMOY: Joi.string(), - RPC_URL_BSC_MAINNET: Joi.string(), - RPC_URL_BSC_TESTNET: Joi.string(), - RPC_URL_MOONBEAM: Joi.string(), - RPC_URL_XLAYER_TESTNET: Joi.string(), - RPC_URL_XLAYER: Joi.string(), + GAS_PRICE_MULTIPLIER: Joi.number().positive(), + RPC_URL_SEPOLIA: Joi.string().uri({ scheme: ['http', 'https'] }), + RPC_URL_POLYGON: Joi.string().uri({ scheme: ['http', 'https'] }), + RPC_URL_POLYGON_AMOY: Joi.string().uri({ scheme: ['http', 'https'] }), + RPC_URL_BSC_MAINNET: Joi.string().uri({ scheme: ['http', 'https'] }), + RPC_URL_BSC_TESTNET: Joi.string().uri({ scheme: ['http', 'https'] }), + RPC_URL_MOONBEAM: Joi.string().uri({ scheme: ['http', 'https'] }), + RPC_URL_XLAYER_TESTNET: Joi.string().uri({ scheme: ['http', 'https'] }), + RPC_URL_XLAYER: Joi.string().uri({ scheme: ['http', 'https'] }), RPC_URL_LOCALHOST: Joi.string(), // S3 S3_ENDPOINT: Joi.string(), - S3_PORT: Joi.string(), + S3_PORT: Joi.number().integer(), S3_ACCESS_KEY: Joi.string().required(), S3_SECRET_KEY: Joi.string().required(), S3_BUCKET: Joi.string(), - S3_USE_SSL: Joi.string(), + S3_USE_SSL: Joi.string().valid('true', 'false'), // Email SENDGRID_API_KEY: Joi.string(), - EMAIL_FROM: Joi.string(), + EMAIL_FROM: Joi.string().email(), EMAIL_FROM_NAME: Joi.string(), // Reputation Level REPUTATION_LEVEL_LOW: Joi.number(), REPUTATION_LEVEL_HIGH: Joi.number(), // Encryption - PGP_PRIVATE_KEY: Joi.string().optional(), - PGP_PASSPHRASE: Joi.string().optional(), - PGP_ENCRYPT: Joi.string(), + PGP_PRIVATE_KEY: Joi.string(), + PGP_PASSPHRASE: Joi.string(), + PGP_ENCRYPT: Joi.string().valid('true', 'false'), // Kyc KYC_API_KEY: Joi.string(), KYC_API_PRIVATE_KEY: Joi.string().required(), - KYC_BASE_URL: Joi.string(), + KYC_BASE_URL: Joi.string().uri({ scheme: ['http', 'https'] }), // Human App HUMAN_APP_EMAIL: Joi.string().email().required(), diff --git a/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts b/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts index acb723539d..32547f8c59 100644 --- a/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/pgp-config.service.ts @@ -10,7 +10,7 @@ export class PGPConfigService { * Default: false */ get encrypt(): boolean { - return this.configService.get('PGP_ENCRYPT', false); + return this.configService.get('PGP_ENCRYPT', 'false') === 'true'; } /** From 7c1b969c1e35bf8889007826e5fe7bd27aff7cbf Mon Sep 17 00:00:00 2001 From: mpblocky <185767042+mpblocky@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:22:02 +0100 Subject: [PATCH 25/29] [HUMAN App] refactor: my jobs table components (#3186) --- .../modules/worker/jobs/components/index.ts | 1 - .../jobs/components/my-jobs-table-actions.tsx | 2 +- .../src/modules/worker/jobs/hooks/index.ts | 1 + .../use-get-my-jobs-data.ts} | 39 +--- .../src/modules/worker/jobs/jobs.page.tsx | 24 +- .../my-jobs/components/desktop/columns.tsx | 198 ++++++++++++++++ .../components/desktop/my-jobs-table.tsx | 221 +----------------- .../components/{ => desktop}/status-chip.tsx | 4 +- .../components/mobile/my-jobs-list-mobile.tsx | 9 +- .../modules/worker/jobs/my-jobs/schemas.ts | 27 +++ 10 files changed, 264 insertions(+), 262 deletions(-) rename packages/apps/human-app/frontend/src/modules/worker/jobs/{components/my-jobs-data.ts => hooks/use-get-my-jobs-data.ts} (60%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx rename packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/{ => desktop}/status-chip.tsx (89%) create mode 100644 packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/schemas.ts diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts index 0fb14ebde7..9dee5f52de 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/index.ts @@ -1,6 +1,5 @@ export * from './evm-address'; export * from './jobs-tab-panel'; -export * from './my-jobs-data'; export * from './my-jobs-table-actions'; export * from './escrow-address-search-form'; export * from './reject-button'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-table-actions.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-table-actions.tsx index 76c4f49466..3d2d5341ad 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-table-actions.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-table-actions.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { TableButton } from '@/shared/components/ui/table-button'; import { useRejectTaskMutation } from '../my-jobs/hooks'; import { MyJobStatus } from '../types'; -import { type MyJob } from './my-jobs-data'; +import { type MyJob } from '../my-jobs/schemas'; import { RejectButton } from './reject-button'; interface MyJobsTableRejectActionProps { diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts index eceb07072a..d0d0034f9d 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/index.ts @@ -4,3 +4,4 @@ export * from './use-job-types-oracles-table'; export * from './use-jobs-filter-store'; export * from './use-my-jobs-filter-store'; export * from './use-get-ui-config'; +export * from './use-get-my-jobs-data'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-data.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-get-my-jobs-data.ts similarity index 60% rename from packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-data.ts rename to packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-get-my-jobs-data.ts index aac364251f..f293ebb507 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/components/my-jobs-data.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/hooks/use-get-my-jobs-data.ts @@ -1,41 +1,14 @@ -/* eslint-disable camelcase -- api response*/ +/* eslint-disable camelcase */ import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { z } from 'zod'; import { useParams } from 'react-router-dom'; import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; import { stringifyUrlQueryObject } from '@/shared/helpers/transfomers'; -import { createPaginationSchema } from '@/shared/helpers/pagination'; -import { MyJobStatus, UNKNOWN_JOB_STATUS } from '../types'; -import { type MyJobsFilterStoreProps, useMyJobsFilterStore } from '../hooks'; - -const myJobSchema = z.object({ - assignment_id: z.string(), - escrow_address: z.string(), - chain_id: z.number(), - job_type: z.string(), - status: z.string().transform((value) => { - try { - return z.nativeEnum(MyJobStatus).parse(value.toUpperCase()); - } catch (error) { - return UNKNOWN_JOB_STATUS; - } - }), - reward_amount: z.string(), - reward_token: z.string(), - created_at: z.string(), - expires_at: z.string(), - url: z.string().optional().nullable(), -}); - -const myJobsSuccessResponseSchema = createPaginationSchema(myJobSchema); - -export type MyJob = z.infer; -export type MyJobsSuccessResponse = z.infer; -export interface MyJobsWithJobTypes { - jobTypes: string[]; - jobs: MyJobsSuccessResponse; -} +import { myJobsSuccessResponseSchema } from '../my-jobs/schemas'; +import { + useMyJobsFilterStore, + type MyJobsFilterStoreProps, +} from './use-my-jobs-filter-store'; type GetMyJobTableDataDto = MyJobsFilterStoreProps['filterParams'] & { oracle_address: string; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx index c4c0abfb55..cc211c2853 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx @@ -114,19 +114,17 @@ export function JobsPage() { backgroundColor: isMobile ? 'transparent' : undefined, }} > -
- {!isError && ( - - {oracleName} - - )} -
+ {!isError && ( + + {oracleName} + + )} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx new file mode 100644 index 0000000000..0ae8322952 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx @@ -0,0 +1,198 @@ +/* eslint-disable camelcase -- ...*/ +import { t } from 'i18next'; +import Grid from '@mui/material/Grid'; +import { type MRT_ColumnDef } from 'material-react-table'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { TableHeaderCell } from '@/shared/components/ui/table/table-header-cell'; +import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; +import { Button } from '@/shared/components/ui/button'; +import { Chip } from '@/shared/components/ui/chip'; +import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; +import { formatDate } from '@/shared/helpers/date'; +import { + EvmAddress, + RewardAmount, + MyJobsTableActions, +} from '../../../components'; +import { type MyJob } from '../../schemas'; +import { StatusChip } from './status-chip'; +import { MyJobsExpiresAtSort } from './my-jobs-expires-at-sort'; +import { MyJobsJobTypeFilter } from './my-jobs-job-type-filter'; +import { MyJobsNetworkFilter } from './my-jobs-network-filter'; +import { MyJobsRewardAmountSort } from './my-jobs-reward-amount-sort'; +import { MyJobsStatusFilter } from './my-jobs-status-filter'; + +export const getColumnsDefinition = ({ + refreshData, + isRefreshTasksPending, + chainIdsEnabled, +}: { + refreshData: () => void; + isRefreshTasksPending: boolean; + chainIdsEnabled: number[]; +}): MRT_ColumnDef[] => [ + { + accessorKey: 'escrow_address', + header: t('worker.jobs.escrowAddress'), + size: 100, + enableSorting: true, + Cell: (props) => { + return ; + }, + }, + { + accessorKey: 'network', + header: t('worker.jobs.network'), + size: 100, + Cell: (props) => { + return getNetworkName(props.row.original.chain_id); + }, + muiTableHeadCellProps: () => ({ + component: (props) => { + return ( + + } + /> + ); + }, + }), + }, + { + accessorKey: 'reward_amount', + header: t('worker.jobs.rewardAmount'), + size: 100, + enableSorting: true, + Cell: (props) => { + const { reward_amount, reward_token } = props.row.original; + return ( + + ); + }, + muiTableHeadCellProps: () => ({ + component: (props) => ( + } + /> + ), + }), + }, + { + accessorKey: 'job_type', + header: t('worker.jobs.jobType'), + size: 100, + enableSorting: true, + Cell: ({ row }) => { + const label = t(`jobTypeLabels.${row.original.job_type as JobType}`); + return ; + }, + muiTableHeadCellProps: () => ({ + component: (props) => { + return ( + } + /> + ); + }, + }), + }, + { + accessorKey: 'expires_at', + header: t('worker.jobs.expiresAt'), + size: 100, + enableSorting: true, + Cell: (props) => { + return formatDate(props.row.original.expires_at); + }, + muiTableHeadCellProps: () => ({ + component: (props) => { + return ( + } + /> + ); + }, + }), + }, + { + accessorKey: 'status', + header: t('worker.jobs.status'), + size: 100, + enableSorting: true, + Cell: (props) => { + const status = props.row.original.status; + return ; + }, + muiTableHeadCellProps: () => ({ + component: (props) => { + return ( + } + /> + ); + }, + }), + }, + { + accessorKey: 'assignment_id', + header: t('worker.jobs.refresh'), + size: 100, + enableSorting: true, + Cell: (props) => ( + + + + ), + muiTableHeadCellProps: () => ({ + component: (props) => { + return ( +
+ ); + }, + }), + }, +]; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-table.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-table.tsx index 923b27e1b4..cee3a0ec9f 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-table.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-table.tsx @@ -1,218 +1,22 @@ /* eslint-disable camelcase -- ...*/ import { t } from 'i18next'; -import { useEffect, useMemo, useState } from 'react'; -import Grid from '@mui/material/Grid'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { MaterialReactTable, useMaterialReactTable, - type MRT_ColumnDef, } from 'material-react-table'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { TableHeaderCell } from '@/shared/components/ui/table/table-header-cell'; -import { getNetworkName } from '@/modules/smart-contracts/get-network-name'; -import { Button } from '@/shared/components/ui/button'; -import { Chip } from '@/shared/components/ui/chip'; import { useColorMode } from '@/shared/contexts/color-mode'; import { createTableDarkMode } from '@/shared/styles/create-table-dark-mode'; -import type { JobType } from '@/modules/smart-contracts/EthKVStore/config'; -import { formatDate } from '@/shared/helpers/date'; -import { StatusChip } from '../status-chip'; -import { - type MyJob, - EvmAddress, - RewardAmount, - MyJobsTableActions, - useGetMyJobsData, - EscrowAddressSearchForm, -} from '../../../components'; -import { useMyJobsFilterStore } from '../../../hooks'; +import { EscrowAddressSearchForm } from '../../../components'; +import { useGetMyJobsData, useMyJobsFilterStore } from '../../../hooks'; import { useRefreshTasksMutation } from '../../hooks'; -import { MyJobsExpiresAtSort } from './my-jobs-expires-at-sort'; -import { MyJobsJobTypeFilter } from './my-jobs-job-type-filter'; -import { MyJobsNetworkFilter } from './my-jobs-network-filter'; -import { MyJobsRewardAmountSort } from './my-jobs-reward-amount-sort'; -import { MyJobsStatusFilter } from './my-jobs-status-filter'; +import { getColumnsDefinition } from './columns'; interface MyJobsTableProps { chainIdsEnabled: number[]; } -const getColumnsDefinition = ({ - refreshData, - isRefreshTasksPending, - chainIdsEnabled, -}: { - refreshData: () => void; - isRefreshTasksPending: boolean; - chainIdsEnabled: number[]; -}): MRT_ColumnDef[] => [ - { - accessorKey: 'escrow_address', - header: t('worker.jobs.escrowAddress'), - size: 100, - enableSorting: true, - Cell: (props) => { - return ; - }, - }, - { - accessorKey: 'network', - header: t('worker.jobs.network'), - size: 100, - Cell: (props) => { - return getNetworkName(props.row.original.chain_id); - }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - - } - /> - ); - }, - }), - }, - { - accessorKey: 'reward_amount', - header: t('worker.jobs.rewardAmount'), - size: 100, - enableSorting: true, - Cell: (props) => { - const { reward_amount, reward_token } = props.row.original; - return ( - - ); - }, - muiTableHeadCellProps: () => ({ - component: (props) => ( - } - /> - ), - }), - }, - { - accessorKey: 'job_type', - header: t('worker.jobs.jobType'), - size: 100, - enableSorting: true, - Cell: ({ row }) => { - const label = t(`jobTypeLabels.${row.original.job_type as JobType}`); - return ; - }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), - }, - { - accessorKey: 'expires_at', - header: t('worker.jobs.expiresAt'), - size: 100, - enableSorting: true, - Cell: (props) => { - return formatDate(props.row.original.expires_at); - }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), - }, - { - accessorKey: 'status', - header: t('worker.jobs.status'), - size: 100, - enableSorting: true, - Cell: (props) => { - const status = props.row.original.status; - return ; - }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), - }, - { - accessorKey: 'assignment_id', - header: t('worker.jobs.refresh'), - size: 100, - enableSorting: true, - Cell: (props) => ( - - - - ), - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - - ); - }, - }), - }, -]; - export function MyJobsTable({ chainIdsEnabled }: Readonly) { const { colorPalette, isDarkMode } = useColorMode(); const { @@ -237,15 +41,14 @@ export function MyJobsTable({ chainIdsEnabled }: Readonly) { pageSize: 5, }); - const refreshTasks = (address: string) => { - return () => { - refreshTasksMutation({ oracle_address: address }); - }; - }; + const refreshData = useCallback(() => { + refreshTasksMutation({ oracle_address: oracle_address ?? '' }); + }, [refreshTasksMutation, oracle_address]); + useEffect(() => { - if (!(paginationState.pageSize === 5 || paginationState.pageSize === 10)) - return; - setPageParams(paginationState.pageIndex, paginationState.pageSize); + if (paginationState.pageSize === 5 || paginationState.pageSize === 10) { + setPageParams(paginationState.pageIndex, paginationState.pageSize); + } }, [paginationState, setPageParams]); useEffect(() => { @@ -263,7 +66,7 @@ export function MyJobsTable({ chainIdsEnabled }: Readonly) { const table = useMaterialReactTable({ columns: getColumnsDefinition({ - refreshData: refreshTasks(oracle_address ?? ''), + refreshData, isRefreshTasksPending, chainIdsEnabled, }), diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/status-chip.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/status-chip.tsx similarity index 89% rename from packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/status-chip.tsx rename to packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/status-chip.tsx index ebc6e7db65..dc64a3abfa 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/status-chip.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/status-chip.tsx @@ -2,8 +2,8 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { colorPalette as lightModeColorPalette } from '@/shared/styles/color-palette'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { type MyJob } from '../../components'; -import { getChipStatusColor } from '../utils'; +import { getChipStatusColor } from '../../utils'; +import { type MyJob } from '../../schemas'; export function StatusChip({ status }: Readonly<{ status: MyJob['status'] }>) { const { colorPalette } = useColorMode(); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx index ece4d8d966..e930cda990 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-list-mobile.tsx @@ -17,16 +17,19 @@ import { colorPalette as lightModeColorPalette } from '@/shared/styles/color-pal import { formatDate } from '@/shared/helpers/date'; import { useCombinePages } from '@/shared/hooks'; import { - useInfiniteGetMyJobsData, - type MyJob, EscrowAddressSearchForm, EvmAddress, RewardAmount, MyJobsTableActions, } from '../../../components'; -import { useMyJobsFilterStore, useJobsFilterStore } from '../../../hooks'; +import { + useMyJobsFilterStore, + useJobsFilterStore, + useInfiniteGetMyJobsData, +} from '../../../hooks'; import { useRefreshTasksMutation } from '../../hooks'; import { getChipStatusColor } from '../../utils'; +import { type MyJob } from '../../schemas'; interface MyJobsListMobileProps { setIsMobileFilterDrawerOpen: Dispatch>; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/schemas.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/schemas.ts new file mode 100644 index 0000000000..7801675733 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/schemas.ts @@ -0,0 +1,27 @@ +/* eslint-disable camelcase -- api response*/ +import { z } from 'zod'; +import { createPaginationSchema } from '@/shared/helpers/pagination'; +import { MyJobStatus, UNKNOWN_JOB_STATUS } from '../types'; + +const myJobSchema = z.object({ + assignment_id: z.string(), + escrow_address: z.string(), + chain_id: z.number(), + job_type: z.string(), + status: z.string().transform((value) => { + try { + return z.nativeEnum(MyJobStatus).parse(value.toUpperCase()); + } catch (error) { + return UNKNOWN_JOB_STATUS; + } + }), + reward_amount: z.string(), + reward_token: z.string(), + created_at: z.string(), + expires_at: z.string(), + url: z.string().optional().nullable(), +}); + +export const myJobsSuccessResponseSchema = createPaginationSchema(myJobSchema); + +export type MyJob = z.infer; From 49e8c8255d24d611f5cde5cc29cc0774e2955dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:34:00 +0100 Subject: [PATCH 26/29] WIP: 3a1a3cdd4 refactor: update token decimals handling and improve input validation in payment forms (#3200) --- .../job-launcher/client/src/components/Jobs/Table.tsx | 3 ++- .../client/src/pages/Job/JobDetail/index.tsx | 8 ++++---- packages/apps/job-launcher/client/src/types/index.ts | 1 + .../apps/job-launcher/server/src/modules/job/job.dto.ts | 9 +++++++++ .../job-launcher/server/src/modules/job/job.service.ts | 2 ++ 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Table.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Table.tsx index aa3d6eb183..c83328c2ba 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Table.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Table.tsx @@ -62,7 +62,8 @@ export const JobTable = ({ { id: 'fundAmount', label: 'Fund Amount', - render: ({ fundAmount }) => `${fundAmount} HMT`, + render: ({ fundAmount, currency }) => + `${Number(fundAmount)} ${currency.toUpperCase()}`, }, { id: 'status', label: 'Status' }, { diff --git a/packages/apps/job-launcher/client/src/pages/Job/JobDetail/index.tsx b/packages/apps/job-launcher/client/src/pages/Job/JobDetail/index.tsx index 6107d08449..d6f0cd39ad 100644 --- a/packages/apps/job-launcher/client/src/pages/Job/JobDetail/index.tsx +++ b/packages/apps/job-launcher/client/src/pages/Job/JobDetail/index.tsx @@ -133,11 +133,11 @@ export default function JobDetail() { /> @@ -180,7 +180,7 @@ export default function JobDetail() { /> diff --git a/packages/apps/job-launcher/client/src/types/index.ts b/packages/apps/job-launcher/client/src/types/index.ts index 49051835df..8a03ce900f 100644 --- a/packages/apps/job-launcher/client/src/types/index.ts +++ b/packages/apps/job-launcher/client/src/types/index.ts @@ -263,6 +263,7 @@ export type JobDetailsResponse = { manifestHash: string; balance: number; paidOut: number; + currency?: string; status: string; }; manifest: { diff --git a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts index 16658a23c8..8ac816657e 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts @@ -328,6 +328,12 @@ export class CommonDetails { @Min(0) public paidOut: number; + @ApiProperty({ + description: 'Currency of the job', + }) + @IsEnumCaseInsensitive(EscrowFundToken) + public currency?: EscrowFundToken; + @ApiProperty({ description: 'Number of tasks (optional)', name: 'amount_of_tasks', @@ -477,6 +483,9 @@ export class JobListDto { @ApiProperty({ name: 'fund_amount' }) public fundAmount: number; + @ApiProperty() + public currency: EscrowFundToken; + @ApiProperty() public status: JobStatus; } diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 87e20e9eee..2b0ff795bb 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -1283,6 +1283,7 @@ export class JobService { escrowAddress: job.escrowAddress, network: NETWORKS[job.chainId as ChainId]!.title, fundAmount: job.fundAmount, + currency: job.token as EscrowFundToken, status: job.status, }; }); @@ -1615,6 +1616,7 @@ export class JobService { manifestHash, balance: Number(ethers.formatEther(escrow?.balance || 0)), paidOut: Number(ethers.formatEther(escrow?.amountPaid || 0)), + currency: jobEntity.token as EscrowFundToken, status: jobEntity.status, failedReason: jobEntity.failedReason, }, From dffbfd9466eb6d95471996ebe0c4d3c150981503 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:35:31 +0100 Subject: [PATCH 27/29] chore(deps): bump @tanstack/query-sync-storage-persister (#3199) Bumps [@tanstack/query-sync-storage-persister](https://github.com/TanStack/query/tree/HEAD/packages/query-sync-storage-persister) from 5.66.0 to 5.68.0. - [Release notes](https://github.com/TanStack/query/releases) - [Commits](https://github.com/TanStack/query/commits/v5.68.0/packages/query-sync-storage-persister) --- updated-dependencies: - dependency-name: "@tanstack/query-sync-storage-persister" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../exchange-oracle/client/package.json | 2 +- .../apps/job-launcher/client/package.json | 2 +- packages/apps/staking/package.json | 2 +- yarn.lock | 73 +++++++++++++------ 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/packages/apps/fortune/exchange-oracle/client/package.json b/packages/apps/fortune/exchange-oracle/client/package.json index b454466a10..35712e7cee 100644 --- a/packages/apps/fortune/exchange-oracle/client/package.json +++ b/packages/apps/fortune/exchange-oracle/client/package.json @@ -27,7 +27,7 @@ "dependencies": { "@human-protocol/sdk": "*", "@mui/material": "^5.16.7", - "@tanstack/query-sync-storage-persister": "^5.59.0", + "@tanstack/query-sync-storage-persister": "^5.68.0", "@tanstack/react-query": "^5.67.2", "@tanstack/react-query-persist-client": "^5.67.2", "axios": "^1.7.2", diff --git a/packages/apps/job-launcher/client/package.json b/packages/apps/job-launcher/client/package.json index 6526ca9237..0782ecf949 100644 --- a/packages/apps/job-launcher/client/package.json +++ b/packages/apps/job-launcher/client/package.json @@ -14,7 +14,7 @@ "@reduxjs/toolkit": "^2.5.0", "@stripe/react-stripe-js": "^3.0.0", "@stripe/stripe-js": "^4.2.0", - "@tanstack/query-sync-storage-persister": "^5.59.0", + "@tanstack/query-sync-storage-persister": "^5.68.0", "@tanstack/react-query": "^5.67.2", "@tanstack/react-query-persist-client": "^5.67.2", "axios": "^1.1.3", diff --git a/packages/apps/staking/package.json b/packages/apps/staking/package.json index e201c839eb..7b2b1ddf9b 100644 --- a/packages/apps/staking/package.json +++ b/packages/apps/staking/package.json @@ -29,7 +29,7 @@ "@human-protocol/sdk": "*", "@mui/icons-material": "^6.4.6", "@mui/material": "^5.16.7", - "@tanstack/query-sync-storage-persister": "^5.59.0", + "@tanstack/query-sync-storage-persister": "^5.68.0", "@tanstack/react-query": "^5.67.2", "@tanstack/react-query-persist-client": "^5.67.2", "axios": "^1.7.2", diff --git a/yarn.lock b/yarn.lock index db52b8a003..44b7deca97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6068,28 +6068,21 @@ dependencies: remove-accents "0.5.0" -"@tanstack/query-core@5.66.0": - version "5.66.0" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.66.0.tgz#163f670b3b4e3b3cdbff6698ad44b2edfcaed185" - integrity sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw== - "@tanstack/query-core@5.67.2": version "5.67.2" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.67.2.tgz#3d59a7dd3613321465925975510c0bb8e603a064" integrity sha512-+iaFJ/pt8TaApCk6LuZ0WHS/ECVfTzrxDOEL9HH9Dayyb5OVuomLzDXeSaI2GlGT/8HN7bDGiRXDts3LV+u6ww== +"@tanstack/query-core@5.68.0": + version "5.68.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.68.0.tgz#3765573de58741c68fb80b128d3e3ffb4d80cb68" + integrity sha512-r8rFYYo8/sY/LNaOqX84h12w7EQev4abFXDWy4UoDVUJzJ5d9Fbmb8ayTi7ScG+V0ap44SF3vNs/45mkzDGyGw== + "@tanstack/query-devtools@5.65.0": version "5.65.0" resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.65.0.tgz#37da5e911543b4f6d98b9a04369eab0de6044ba1" integrity sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg== -"@tanstack/query-persist-client-core@5.66.0": - version "5.66.0" - resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.66.0.tgz#9f358b3246a6da9a589894042ec3623395e5cbde" - integrity sha512-y6bolShmbOmexexZfnmTiHsBOm4ruJxc2MQJzqi6PxkSPjkKAU+Zqviy4ZwQos5skwSw0g7b9L6V6Rd64oZc9g== - dependencies: - "@tanstack/query-core" "5.66.0" - "@tanstack/query-persist-client-core@5.67.2": version "5.67.2" resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.67.2.tgz#7a3f5859784b09f88e83ea801a46ac97b52d56ca" @@ -6097,13 +6090,20 @@ dependencies: "@tanstack/query-core" "5.67.2" -"@tanstack/query-sync-storage-persister@^5.59.0": - version "5.66.0" - resolved "https://registry.yarnpkg.com/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.66.0.tgz#ca92e68bd17ca102d4eaaa131b6a1a9be6060480" - integrity sha512-G/QCN1eWGgwIEkjbjas2IJTy1dXYnMcJV61wKtmFB7eFNiqDa5YdWTEynJs4UOsdh9HsvFq5JH/DN8AA/zpKWw== +"@tanstack/query-persist-client-core@5.68.0": + version "5.68.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.68.0.tgz#ef0901275b1a974451c845f285201ac748ead54b" + integrity sha512-SP5E9lyC1/yHqfIrfIoGD5By0hh3zv0kMMWWtWjznHPb6Ghb0INCoNJr0oD5f+DQZAen+mJmwQ0x9LlAZdle6Q== + dependencies: + "@tanstack/query-core" "5.68.0" + +"@tanstack/query-sync-storage-persister@^5.68.0": + version "5.68.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.68.0.tgz#456f046cc70405b6852762718d2c9ff695de4a27" + integrity sha512-Zd0ukP58F4uLDol5QoP6NCgRdMl9+3InW4ojg7VITpP6QLcjbJqQ4DG5miOhSElM/xASbW2wS/AoSSguboA5Lw== dependencies: - "@tanstack/query-core" "5.66.0" - "@tanstack/query-persist-client-core" "5.66.0" + "@tanstack/query-core" "5.68.0" + "@tanstack/query-persist-client-core" "5.68.0" "@tanstack/react-query-devtools@^5.59.16": version "5.66.0" @@ -18520,7 +18520,16 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18629,7 +18638,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -18643,6 +18652,13 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -19860,9 +19876,9 @@ victory-vendor@^36.6.8: d3-timer "^3.0.1" viem@2.7.14, viem@^2.15.1: - version "2.23.9" - resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.9.tgz#b28a77aa0f0c7ab1db0cb82eed95e6dbf7a19397" - integrity sha512-y8VLPfKukrstZKTerS9bm45ajZ22wUyStF+VquK3I2OovWLOyXSbQmJWei8syMFhp1uwhxh1tb0fAdx0WSRZWg== + version "2.23.11" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.11.tgz#c087a52f3c08a9cf4dadd4b273a1d30c62b249e2" + integrity sha512-yPkHJt4Vn88kLlrv8mrtVN54PW4vNLWRWDScf8SaHK2f44VlMk5IZbMJw4ycUoW9K9GUvCMrYuUa34MAcwYHIg== dependencies: "@noble/curves" "1.8.1" "@noble/hashes" "1.7.1" @@ -20556,7 +20572,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -20574,6 +20590,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 098abfccd4e7514fa3aaf2db14fbb297ad65af95 Mon Sep 17 00:00:00 2001 From: Dmitry Nechay Date: Tue, 18 Mar 2025 12:49:16 +0300 Subject: [PATCH 28/29] [Reputation Oracle] refactor: user module (#3174) --- .../reputation-oracle/server/package.json | 1 + .../server/src/app.controller.ts | 14 + .../server/src/app.module.ts | 15 +- .../server/src/common/enums/index.ts | 1 - .../server/src/common/enums/site-key.ts | 4 - .../server/src/common/enums/user.ts | 4 - .../src/common/guards/signature.auth.spec.ts | 9 +- .../server/src/common/interfaces/base.ts | 5 - .../server/src/common/interfaces/cron-job.ts | 8 - .../server/src/database/base.entity.ts | 20 +- .../server/src/database/base.repository.ts | 42 +- .../server/src/database/database.module.ts | 1 + .../1741875593626-fixSitekeyUserRef.ts | 17 + .../src/integrations/hcaptcha/fixtures.ts | 5 +- .../server/src/modules/auth/auth.module.ts | 2 +- .../src/modules/auth/auth.service.spec.ts | 68 +- .../server/src/modules/auth/auth.service.ts | 70 +- .../jwt.http.ts => jwt-http-strategy.ts} | 17 +- .../server/src/modules/auth/strategy/index.ts | 1 - .../server/src/modules/auth/token.entity.ts | 26 +- .../src/modules/auth/token.repository.ts | 4 +- .../src/modules/cron-job/cron-job.entity.ts | 19 +- .../modules/cron-job/cron-job.service.spec.ts | 1 + .../src/modules/cron-job/cron-job.service.ts | 1 + .../server/src/modules/kyc/fixtures.ts | 34 + .../server/src/modules/kyc/kyc.entity.ts | 10 +- .../server/src/modules/kyc/kyc.repository.ts | 4 +- .../src/modules/kyc/kyc.service.spec.ts | 146 +-- .../server/src/modules/nda/fixtures.ts | 7 + .../src/modules/nda/nda.service.spec.ts | 37 +- .../qualification/qualification.entity.ts | 7 +- .../qualification/qualification.repository.ts | 1 + .../qualification.service.spec.ts | 22 +- .../qualification/qualification.service.ts | 60 +- .../user-qualification.entity.ts | 14 +- .../src/modules/reputation/reputation.dto.ts | 2 - .../server/src/modules/user/fixtures/index.ts | 2 + .../src/modules/user/fixtures/sitekey.ts | 29 + .../server/src/modules/user/fixtures/user.ts | 73 ++ .../server/src/modules/user/index.ts | 6 +- .../src/modules/user/site-key.entity.ts | 28 +- .../src/modules/user/site-key.repository.ts | 16 +- .../server/src/modules/user/types.ts | 21 + .../src/modules/user/user.controller.ts | 27 +- .../server/src/modules/user/user.dto.ts | 54 +- .../server/src/modules/user/user.entity.ts | 73 +- .../src/modules/user/user.error.filter.ts | 3 +- .../server/src/modules/user/user.module.ts | 7 +- .../src/modules/user/user.repository.ts | 88 +- .../src/modules/user/user.service.spec.ts | 1062 +++++++---------- .../server/src/modules/user/user.service.ts | 205 ++-- .../server/src/modules/web3/fixtures.ts | 20 + .../src/modules/web3/web3.service.spec.ts | 35 +- .../server/src/utils/security.ts | 14 + .../server/src/utils/web3.spec.ts | 84 +- .../server/src/utils/web3.ts | 2 +- .../server/test/fixtures/web3.ts | 8 +- .../server/test/mock-creators/web3.ts | 9 - .../server/typeorm-migrations-datasource.ts | 1 + yarn.lock | 5 + 60 files changed, 1362 insertions(+), 1209 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/app.controller.ts delete mode 100644 packages/apps/reputation-oracle/server/src/common/enums/site-key.ts delete mode 100644 packages/apps/reputation-oracle/server/src/common/enums/user.ts delete mode 100644 packages/apps/reputation-oracle/server/src/common/interfaces/base.ts delete mode 100644 packages/apps/reputation-oracle/server/src/common/interfaces/cron-job.ts create mode 100644 packages/apps/reputation-oracle/server/src/database/migrations/1741875593626-fixSitekeyUserRef.ts rename packages/apps/reputation-oracle/server/src/modules/auth/{strategy/jwt.http.ts => jwt-http-strategy.ts} (76%) delete mode 100644 packages/apps/reputation-oracle/server/src/modules/auth/strategy/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/kyc/fixtures.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/nda/fixtures.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/user/fixtures/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/user/fixtures/sitekey.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/user/types.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts create mode 100644 packages/apps/reputation-oracle/server/src/utils/security.ts delete mode 100644 packages/apps/reputation-oracle/server/test/mock-creators/web3.ts diff --git a/packages/apps/reputation-oracle/server/package.json b/packages/apps/reputation-oracle/server/package.json index 2d0e70dcc8..2cff88a632 100644 --- a/packages/apps/reputation-oracle/server/package.json +++ b/packages/apps/reputation-oracle/server/package.json @@ -95,6 +95,7 @@ "ts-loader": "^9.2.3", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", + "type-fest": "^4.37.0", "typescript": "^5.6.3" }, "lint-staged": { diff --git a/packages/apps/reputation-oracle/server/src/app.controller.ts b/packages/apps/reputation-oracle/server/src/app.controller.ts new file mode 100644 index 0000000000..bfe63b0c9c --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/app.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Redirect } from '@nestjs/common'; +import { ApiExcludeEndpoint } from '@nestjs/swagger'; +import { Public } from './common/decorators'; + +@Controller() +export class AppController { + @Get('/') + @Public() + @Redirect('/swagger', 301) + @ApiExcludeEndpoint() + public swagger(): string { + return 'OK'; + } +} diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index df1f4c41b5..1c13e15e96 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { ClassSerializerInterceptor, Module } from '@nestjs/common'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; @@ -32,6 +32,7 @@ import { NDAModule } from './modules/nda/nda.module'; import { StorageModule } from './modules/storage/storage.module'; import Environment from './utils/environment'; +import { AppController } from './app.controller'; @Module({ providers: [ @@ -43,10 +44,21 @@ import Environment from './utils/environment'; provide: APP_PIPE, useClass: HttpValidationPipe, }, + /** + * Interceptors are called: + * - for request: in direct order + * - for response: in reverse order + * + * So order matters here for serialization. + */ { provide: APP_INTERCEPTOR, useClass: TransformInterceptor, }, + { + provide: APP_INTERCEPTOR, + useClass: ClassSerializerInterceptor, + }, { provide: APP_FILTER, useClass: ExceptionFilter, @@ -87,5 +99,6 @@ import Environment from './utils/environment'; WebhookIncomingModule, WebhookOutgoingModule, ], + controllers: [AppController], }) export class AppModule {} diff --git a/packages/apps/reputation-oracle/server/src/common/enums/index.ts b/packages/apps/reputation-oracle/server/src/common/enums/index.ts index 9ebd22f5b4..e542fa9df9 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/index.ts @@ -2,5 +2,4 @@ export * from './job'; export * from './reputation'; export * from './webhook'; export * from './collection'; -export * from './site-key'; export * from './hcaptcha'; diff --git a/packages/apps/reputation-oracle/server/src/common/enums/site-key.ts b/packages/apps/reputation-oracle/server/src/common/enums/site-key.ts deleted file mode 100644 index 28d82115fc..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/enums/site-key.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum SiteKeyType { - HCAPTCHA = 'hcaptcha', - REGISTRATION = 'registration', -} diff --git a/packages/apps/reputation-oracle/server/src/common/enums/user.ts b/packages/apps/reputation-oracle/server/src/common/enums/user.ts deleted file mode 100644 index 371c721101..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/enums/user.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum OperatorStatus { - ACTIVE = 'active', - INACTIVE = 'inactive', -} diff --git a/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts b/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts index 03b304aaf9..0f3c3ba478 100644 --- a/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts +++ b/packages/apps/reputation-oracle/server/src/common/guards/signature.auth.spec.ts @@ -69,7 +69,8 @@ describe('SignatureAuthGuard', () => { const guard = new SignatureAuthGuard([role]); const { privateKey, address } = generateEthWallet(); - EscrowUtils.getEscrow = jest.fn().mockResolvedValueOnce({ + + (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ [name]: address, }); @@ -99,7 +100,7 @@ describe('SignatureAuthGuard', () => { const guard = new SignatureAuthGuard([AuthSignatureRole.JOB_LAUNCHER]); const { privateKey, address } = generateEthWallet(); - EscrowUtils.getEscrow = jest.fn().mockResolvedValueOnce({ + (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ launcher: address, }); const signature = await signMessage( @@ -136,7 +137,7 @@ describe('SignatureAuthGuard', () => { ]); const { privateKey, address } = generateEthWallet(); - EscrowUtils.getEscrow = jest.fn().mockResolvedValueOnce({ + (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ launcher: address, exchangeOracle: address, }); @@ -172,7 +173,7 @@ describe('SignatureAuthGuard', () => { ]); const { privateKey } = generateEthWallet(); - EscrowUtils.getEscrow = jest.fn().mockResolvedValueOnce({ + (EscrowUtils.getEscrow as jest.Mock).mockResolvedValueOnce({ launcher: '', exchangeOracle: '', recordingOracle: '', diff --git a/packages/apps/reputation-oracle/server/src/common/interfaces/base.ts b/packages/apps/reputation-oracle/server/src/common/interfaces/base.ts deleted file mode 100644 index 8ad596c4d9..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/interfaces/base.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IBase { - id: number; - createdAt: Date; - updatedAt: Date; -} diff --git a/packages/apps/reputation-oracle/server/src/common/interfaces/cron-job.ts b/packages/apps/reputation-oracle/server/src/common/interfaces/cron-job.ts deleted file mode 100644 index 1335866ba9..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/interfaces/cron-job.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { CronJobType } from '../enums/cron-job'; -import { IBase } from './base'; - -export interface ICronJob extends IBase { - cronJobType: CronJobType; - startedAt: Date; - completedAt?: Date | null; -} diff --git a/packages/apps/reputation-oracle/server/src/database/base.entity.ts b/packages/apps/reputation-oracle/server/src/database/base.entity.ts index 0779d61674..3476b978d7 100644 --- a/packages/apps/reputation-oracle/server/src/database/base.entity.ts +++ b/packages/apps/reputation-oracle/server/src/database/base.entity.ts @@ -1,9 +1,4 @@ -import { - BeforeInsert, - BeforeUpdate, - Column, - PrimaryGeneratedColumn, -} from 'typeorm'; +import { Column, PrimaryGeneratedColumn } from 'typeorm'; export abstract class BaseEntity { @PrimaryGeneratedColumn() @@ -14,17 +9,4 @@ export abstract class BaseEntity { @Column({ type: 'timestamptz' }) updatedAt: Date; - - @BeforeInsert() - protected beforeInsert(): void { - const date = new Date(); - this.createdAt = date; - this.updatedAt = date; - } - - @BeforeUpdate() - protected beforeUpdate(): void { - const date = new Date(); - this.updatedAt = date; - } } diff --git a/packages/apps/reputation-oracle/server/src/database/base.repository.ts b/packages/apps/reputation-oracle/server/src/database/base.repository.ts index 6a35aeda58..7f6e16341f 100644 --- a/packages/apps/reputation-oracle/server/src/database/base.repository.ts +++ b/packages/apps/reputation-oracle/server/src/database/base.repository.ts @@ -1,4 +1,3 @@ -import { handleQueryFailedError } from '../common/errors/database'; import { DataSource, EntityTarget, @@ -6,13 +5,25 @@ import { QueryFailedError, Repository, } from 'typeorm'; -export class BaseRepository extends Repository { +import { + DatabaseError, + handleQueryFailedError, +} from '../common/errors/database'; +import { BaseEntity } from './base.entity'; + +export class BaseRepository< + T extends BaseEntity & ObjectLiteral, +> extends Repository { constructor(target: EntityTarget, dataSource: DataSource) { super(target, dataSource.createEntityManager()); } async createUnique(item: T): Promise { try { + const date = new Date(); + item.createdAt = date; + item.updatedAt = date; + await this.insert(item); } catch (error) { if (error instanceof QueryFailedError) { @@ -26,6 +37,7 @@ export class BaseRepository extends Repository { async updateOne(item: T): Promise { try { + item.updatedAt = new Date(); await this.save(item); } catch (error) { if (error instanceof QueryFailedError) { @@ -37,6 +49,32 @@ export class BaseRepository extends Repository { return item; } + async updateOneById( + id: T['id'], + partialEntity: Partial, + ): Promise { + try { + const result = await this.update(id, { + ...partialEntity, + updatedAt: new Date(), + }); + + if (result.affected === undefined) { + throw new DatabaseError( + 'Driver "update" operation does not provide expected result', + ); + } + + return result.affected > 0; + } catch (error) { + if (error instanceof QueryFailedError) { + throw handleQueryFailedError(error); + } else { + throw error; + } + } + } + async deleteOne(item: T): Promise { try { await this.remove(item); diff --git a/packages/apps/reputation-oracle/server/src/database/database.module.ts b/packages/apps/reputation-oracle/server/src/database/database.module.ts index 8db906b095..4326dfc0fa 100644 --- a/packages/apps/reputation-oracle/server/src/database/database.module.ts +++ b/packages/apps/reputation-oracle/server/src/database/database.module.ts @@ -38,6 +38,7 @@ import { TypeOrmLoggerModule, TypeOrmLoggerService } from './typeorm'; return { name: 'default-connection', type: 'postgres', + useUTC: true, ...(databaseConfigService.url ? { diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1741875593626-fixSitekeyUserRef.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1741875593626-fixSitekeyUserRef.ts new file mode 100644 index 0000000000..c572734b0b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1741875593626-fixSitekeyUserRef.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixSitekeyUserRef1741875593626 implements MigrationInterface { + name = 'FixSitekeyUserRef1741875593626'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."site_keys" ALTER COLUMN "user_id" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."site_keys" ALTER COLUMN "user_id" DROP NOT NULL`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/fixtures.ts b/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/fixtures.ts index 6c23bee532..1eec11f86c 100644 --- a/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/fixtures.ts +++ b/packages/apps/reputation-oracle/server/src/integrations/hcaptcha/fixtures.ts @@ -1,7 +1,10 @@ import { faker } from '@faker-js/faker'; import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; -export const mockHCaptchaConfigService: Partial = { +export const mockHCaptchaConfigService: Omit< + HCaptchaConfigService, + 'configService' +> = { siteKey: faker.string.uuid(), apiKey: faker.string.uuid(), secret: `E0_${faker.string.alphanumeric()}`, diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts index b21c3a4f48..f8808993ce 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts @@ -7,7 +7,7 @@ import { EmailModule } from '../email/module'; import { UserModule } from '../user'; import { Web3Module } from '../web3/web3.module'; -import { JwtHttpStrategy } from './strategy'; +import { JwtHttpStrategy } from './jwt-http-strategy'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { TokenRepository } from './token.repository'; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index 5b889f71d7..ec1fc2af6e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -43,6 +43,7 @@ import { UserEntity, UserRepository, UserService, + type OperatorUserEntity, } from '../user'; import { Web3Service } from '../web3/web3.service'; import { @@ -169,6 +170,7 @@ describe('AuthService', () => { email: signInDto.email, password: MOCK_HASHED_PASSWORD, status: UserStatus.ACTIVE, + role: UserRole.WORKER, }; let findOneByEmailMock: any; @@ -191,7 +193,15 @@ describe('AuthService', () => { const result = await authService.signin(signInDto); - expect(findOneByEmailMock).toHaveBeenCalledWith(signInDto.email); + expect(findOneByEmailMock).toHaveBeenCalledWith(signInDto.email, { + relations: { + kyc: true, + siteKeys: true, + userQualifications: { + qualification: true, + }, + }, + }); expect(authService.auth).toHaveBeenCalledWith(userEntity); expect(result).toStrictEqual({ accessToken: MOCK_ACCESS_TOKEN, @@ -206,7 +216,15 @@ describe('AuthService', () => { new AuthError(AuthErrorMessage.INVALID_CREDENTIALS), ); - expect(findOneByEmailMock).toHaveBeenCalledWith(signInDto.email); + expect(findOneByEmailMock).toHaveBeenCalledWith(signInDto.email, { + relations: { + kyc: true, + siteKeys: true, + userQualifications: { + qualification: true, + }, + }, + }); }); }); @@ -222,12 +240,13 @@ describe('AuthService', () => { id: 1, email: userCreateDto.email, password: MOCK_HASHED_PASSWORD, + role: UserRole.WORKER, }; let createUserMock: any; beforeEach(() => { - createUserMock = jest.spyOn(userService, 'create'); + createUserMock = jest.spyOn(userService, 'createWorkerUser'); createUserMock.mockResolvedValue(userEntity); @@ -239,15 +258,14 @@ describe('AuthService', () => { }); it('should create a new user and return the user entity', async () => { - const result = await authService.signup(userCreateDto); + await authService.signup(userCreateDto); - expect(userService.create).toHaveBeenCalledWith(userCreateDto); + expect(userService.createWorkerUser).toHaveBeenCalledWith(userCreateDto); expect(tokenRepository.createUnique).toHaveBeenCalledWith({ type: TokenType.EMAIL, - user: userEntity, + userId: userEntity.id, expiresAt: expect.any(Date), }); - expect(result).toBe(userEntity); }); it("should call emailService sendEmail if user's email is valid", async () => { @@ -264,7 +282,7 @@ describe('AuthService', () => { .mockResolvedValue(userEntity as any); await expect(authService.signup(userCreateDto)).rejects.toThrow( - new DuplicatedUserEmailError(userEntity.email), + new DuplicatedUserEmailError(userEntity.email as string), ); expect(userRepository.findOneByEmail).toHaveBeenCalledWith( @@ -417,7 +435,7 @@ describe('AuthService', () => { const tokenEntity: Partial = { uuid: v4(), type: TokenType.EMAIL, - user: userEntity as UserEntity, + userId: userEntity.id, }; let findTokenMock: any; @@ -465,7 +483,9 @@ describe('AuthService', () => { new Date().setDate(new Date().getDate() + 1), ); findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); - userService.updatePassword = jest.fn(); + userService.updatePassword = jest + .fn() + .mockResolvedValueOnce(userEntity); emailService.sendEmail = jest.fn(); const updatePasswordMock = jest.spyOn(userService, 'updatePassword'); @@ -491,7 +511,7 @@ describe('AuthService', () => { const tokenEntity: Partial = { uuid: v4(), type: TokenType.EMAIL, - user: userEntity as UserEntity, + userId: userEntity.id, }; let findTokenMock: any; @@ -512,6 +532,7 @@ describe('AuthService', () => { new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN), ); }); + it('should throw an error if token is expired', () => { tokenEntity.expiresAt = new Date(new Date().getDate() - 1); findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); @@ -527,12 +548,16 @@ describe('AuthService', () => { new Date().setDate(new Date().getDate() + 1), ); findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); - userRepository.updateOne = jest.fn(); + userRepository.updateOneById = jest.fn(); await authService.emailVerification({ token: 'token' }); - expect(userRepository.updateOne).toHaveBeenCalled(); - expect(tokenEntity.user?.status).toBe(UserStatus.ACTIVE); + expect(userRepository.updateOneById).toHaveBeenCalledWith( + userEntity.id, + { + status: UserStatus.ACTIVE, + }, + ); }); }); @@ -615,20 +640,18 @@ describe('AuthService', () => { nonce, }; - let getByAddressMock: any; let updateNonceMock: any; beforeEach(() => { - getByAddressMock = jest.spyOn(userRepository, 'findOneByAddress'); + jest + .spyOn(userService, 'findOperatorUser') + .mockResolvedValue(userEntity as OperatorUserEntity); updateNonceMock = jest.spyOn(userService, 'updateNonce'); jest.spyOn(authService, 'auth').mockResolvedValue({ accessToken: MOCK_ACCESS_TOKEN, refreshToken: MOCK_REFRESH_TOKEN, }); - jest - .spyOn(userRepository, 'findOneByAddress') - .mockResolvedValue({ nonce: nonce } as any); }); afterEach(() => { @@ -636,7 +659,6 @@ describe('AuthService', () => { }); it('should sign in the user, reset nonce and return the JWT', async () => { - getByAddressMock.mockResolvedValue(userEntity as UserEntity); updateNonceMock.mockResolvedValue({ ...userEntity, nonce: nonce1, @@ -655,7 +677,7 @@ describe('AuthService', () => { signature, }); - expect(userRepository.findOneByAddress).toHaveBeenCalledWith( + expect(userService.findOperatorUser).toHaveBeenCalledWith( MOCK_ADDRESS, ); expect(userService.updateNonce).toHaveBeenCalledWith(userEntity); @@ -707,7 +729,7 @@ describe('AuthService', () => { let createUserMock: any; beforeEach(() => { - createUserMock = jest.spyOn(userService, 'createWeb3User'); + createUserMock = jest.spyOn(userService, 'createOperatorUser'); createUserMock.mockResolvedValue(userEntity); @@ -743,7 +765,7 @@ describe('AuthService', () => { signature, }); - expect(userService.createWeb3User).toHaveBeenCalledWith( + expect(userService.createOperatorUser).toHaveBeenCalledWith( web3PreSignUpDto.address, ); diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index fd15becc9c..68c8c2f8bb 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -7,13 +7,16 @@ import { import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { OperatorStatus } from '../../common/enums/user'; import { + SiteKeyType, UserStatus, UserRole, UserEntity, UserRepository, UserService, + OperatorStatus, + type Web2UserEntity, + type OperatorUserEntity, } from '../user'; import { TokenEntity, TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; @@ -21,11 +24,11 @@ import { verifySignature } from '../../utils/web3'; import { Web3Service } from '../web3/web3.service'; import { SignatureType } from '../../common/enums/web3'; import { prepareSignatureBody } from '../../utils/web3'; +import * as securityUtils from '../../utils/security'; import { AuthConfigService } from '../../config/auth-config.service'; import { NDAConfigService } from '../../config/nda-config.service'; import { ServerConfigService } from '../../config/server-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; -import { SiteKeyType } from '../../common/enums'; import { AuthError, AuthErrorMessage, @@ -73,28 +76,28 @@ export class AuthService { email, password, }: Web2SignInDto): Promise { - const userEntity = await this.userRepository.findOneByEmail(email); + const userEntity = await this.userService.findWeb2UserByEmail(email); if (!userEntity) { throw new AuthError(AuthErrorMessage.INVALID_CREDENTIALS); } - if (!UserService.checkPasswordMatchesHash(password, userEntity.password)) { + if (!securityUtils.comparePasswordWithHash(password, userEntity.password)) { throw new AuthError(AuthErrorMessage.INVALID_CREDENTIALS); } return this.auth(userEntity); } - public async signup(data: Web2SignUpDto): Promise { + public async signup(data: Web2SignUpDto): Promise { const storedUser = await this.userRepository.findOneByEmail(data.email); if (storedUser) { throw new DuplicatedUserEmailError(data.email); } - const userEntity = await this.userService.create(data); + const userEntity = await this.userService.createWorkerUser(data); const tokenEntity = new TokenEntity(); tokenEntity.type = TokenType.EMAIL; - tokenEntity.user = userEntity; + tokenEntity.userId = userEntity.id; const date = new Date(); tokenEntity.expiresAt = new Date( date.getTime() + this.authConfigService.verifyEmailTokenExpiresIn * 1000, @@ -104,8 +107,6 @@ export class AuthService { await this.emailService.sendEmail(data.email, EmailAction.SIGNUP, { url: `${this.serverConfigService.feURL}/verify?token=${tokenEntity.uuid}`, }); - - return userEntity; } public async refresh(data: RefreshDto): Promise { @@ -122,10 +123,26 @@ export class AuthService { throw new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED); } - return this.auth(tokenEntity.user); + const userEntity = await this.userRepository.findOneById( + tokenEntity.userId, + { + relations: { + kyc: true, + siteKeys: true, + }, + }, + ); + + if (!userEntity) { + throw new Error('User not found'); + } + + return this.auth(userEntity); } - public async auth(userEntity: UserEntity): Promise { + public async auth( + userEntity: Web2UserEntity | OperatorUserEntity | UserEntity, + ): Promise { const refreshTokenEntity = await this.tokenRepository.findOneByUserIdAndType( userEntity.id, @@ -167,6 +184,7 @@ export class AuthService { : [], }; + // TODO: load sitekeys from repository instead of user entity in request if (userEntity.siteKeys && userEntity.siteKeys.length > 0) { const existingHcaptchaSiteKey = userEntity.siteKeys.find( (key) => key.type === SiteKeyType.HCAPTCHA, @@ -185,7 +203,7 @@ export class AuthService { } const newRefreshTokenEntity = new TokenEntity(); - newRefreshTokenEntity.user = userEntity; + newRefreshTokenEntity.userId = userEntity.id; newRefreshTokenEntity.type = TokenType.REFRESH; const date = new Date(); newRefreshTokenEntity.expiresAt = new Date( @@ -215,7 +233,7 @@ export class AuthService { const tokenEntity = new TokenEntity(); tokenEntity.type = TokenType.PASSWORD; - tokenEntity.user = userEntity; + tokenEntity.userId = userEntity.id; const date = new Date(); tokenEntity.expiresAt = new Date( date.getTime() + this.authConfigService.forgotPasswordExpiresIn * 1000, @@ -241,9 +259,12 @@ export class AuthService { throw new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED); } - await this.userService.updatePassword(tokenEntity.user, data.password); + const userEntity = await this.userService.updatePassword( + tokenEntity.userId, + data.password, + ); await this.emailService.sendEmail( - tokenEntity.user.email, + userEntity.email, EmailAction.PASSWORD_CHANGED, ); @@ -264,8 +285,9 @@ export class AuthService { throw new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED); } - tokenEntity.user.status = UserStatus.ACTIVE; - await this.userRepository.updateOne(tokenEntity.user); + await this.userRepository.updateOneById(tokenEntity.userId, { + status: UserStatus.ACTIVE, + }); } public async resendEmailVerification( @@ -287,7 +309,7 @@ export class AuthService { const tokenEntity = new TokenEntity(); tokenEntity.type = TokenType.EMAIL; - tokenEntity.user = userEntity; + tokenEntity.userId = userEntity.id; const date = new Date(); tokenEntity.expiresAt = new Date( date.getTime() + this.authConfigService.verifyEmailTokenExpiresIn * 1000, @@ -370,15 +392,21 @@ export class AuthService { if (user) { throw new DuplicatedUserAddressError(data.address); } - const userEntity = await this.userService.createWeb3User(data.address); + const userEntity = await this.userService.createOperatorUser(data.address); + + /** + * TODO: revisit if we want to make it active by default + * since we have `enableOperator` functionality and + * they might not have enough tokens, which should not impact signup + */ await kvstore.set(data.address.toLowerCase(), OperatorStatus.ACTIVE); return this.auth(userEntity); } public async web3Signin(data: Web3SignInDto): Promise { - const userEntity = await this.userRepository.findOneByAddress(data.address); + const userEntity = await this.userService.findOperatorUser(data.address); if (!userEntity) { throw new AuthError(AuthErrorMessage.INVALID_ADDRESS); @@ -388,7 +416,7 @@ export class AuthService { from: data.address, to: this.web3ConfigService.operatorAddress, contents: SignatureType.SIGNIN, - nonce: (await this.userRepository.findOneByAddress(data.address))?.nonce, + nonce: userEntity.nonce, }); const verified = verifySignature(preSigninData, data.signature, [ data.address, diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/strategy/jwt.http.ts b/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts similarity index 76% rename from packages/apps/reputation-oracle/server/src/modules/auth/strategy/jwt.http.ts rename to packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts index de3c7120ce..09f653b9b8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/strategy/jwt.http.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts @@ -6,11 +6,11 @@ import { JWT_STRATEGY_NAME, LOGOUT_PATH, RESEND_EMAIL_VERIFICATION_PATH, -} from '../../../common/constants'; -import { UserEntity, UserStatus, UserRepository } from '../../user'; -import { AuthConfigService } from '../../../config/auth-config.service'; -import { TokenRepository } from '../token.repository'; -import { TokenType } from '../token.entity'; +} from '../../common/constants'; +import { UserEntity, UserStatus, UserRepository } from '../user'; +import { AuthConfigService } from '../../config/auth-config.service'; +import { TokenRepository } from './token.repository'; +import { TokenType } from './token.entity'; @Injectable() export class JwtHttpStrategy extends PassportStrategy( @@ -34,7 +34,12 @@ export class JwtHttpStrategy extends PassportStrategy( @Req() request: any, payload: { userId: number }, ): Promise { - const user = await this.userRepository.findById(payload.userId); + const user = await this.userRepository.findOneById(payload.userId, { + relations: { + kyc: true, + siteKeys: true, + }, + }); if (!user) { throw new UnauthorizedException('User not found'); diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/strategy/index.ts b/packages/apps/reputation-oracle/server/src/modules/auth/strategy/index.ts deleted file mode 100644 index e38b86c866..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/auth/strategy/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './jwt.http'; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts index df2497215e..4a1f08f814 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts @@ -6,14 +6,9 @@ import { JoinColumn, ManyToOne, } from 'typeorm'; -/** - * TODO: Leave fix follow-up refactoring - * Importing from '../user' causes circular import error here. - */ -import { UserEntity } from '../user/user.entity'; +import type { UserEntity } from '../user'; import { BaseEntity } from '../../database/base.entity'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; -import { IBase } from '../../common/interfaces/base'; export enum TokenType { EMAIL = 'EMAIL', @@ -21,31 +16,26 @@ export enum TokenType { REFRESH = 'REFRESH', } -export interface IToken extends IBase { - uuid: string; - type: TokenType; -} - @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'tokens' }) @Index(['type', 'userId'], { unique: true }) -export class TokenEntity extends BaseEntity implements IToken { +export class TokenEntity extends BaseEntity { @Column({ type: 'uuid', unique: true }) @Generated('uuid') - public uuid: string; + uuid: string; @Column({ type: 'enum', enum: TokenType, }) - public type: TokenType; + type: TokenType; @Column({ type: 'timestamptz' }) - public expiresAt: Date; + expiresAt: Date; @JoinColumn() - @ManyToOne(() => UserEntity, { eager: true }) - public user: UserEntity; + @ManyToOne('UserEntity') + user?: UserEntity; @Column({ type: 'int' }) - public userId: number; + userId: number; } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts b/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts index 8d2ff80e75..b7e4362051 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts @@ -5,7 +5,7 @@ import { TokenEntity, TokenType } from './token.entity'; @Injectable() export class TokenRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { super(TokenEntity, dataSource); } @@ -18,7 +18,6 @@ export class TokenRepository extends BaseRepository { uuid, type, }, - relations: ['user', 'user.kyc', 'user.siteKeys'], }); } @@ -31,7 +30,6 @@ export class TokenRepository extends BaseRepository { userId, type, }, - relations: ['user'], }); } diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.entity.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.entity.ts index 9c6ee39929..e7c6f3faaa 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.entity.ts @@ -1,30 +1,21 @@ -import { BeforeInsert, Column, Entity, Index } from 'typeorm'; +import { Column, Entity, Index } from 'typeorm'; import { BaseEntity } from '../../database/base.entity'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; -import { ICronJob } from '../../common/interfaces/cron-job'; import { CronJobType } from '../../common/enums/cron-job'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'cron-jobs' }) @Index(['cronJobType'], { unique: true }) -export class CronJobEntity extends BaseEntity implements ICronJob { +export class CronJobEntity extends BaseEntity { @Column({ type: 'enum', enum: CronJobType, }) - public cronJobType: CronJobType; + cronJobType: CronJobType; @Column({ type: 'timestamptz' }) - public startedAt: Date; + startedAt: Date; @Column({ type: 'timestamptz', nullable: true }) - public completedAt?: Date | null; - - @BeforeInsert() - public beforeInsert(): void { - const date = new Date(); - this.startedAt = date; - this.createdAt = date; - this.updatedAt = date; - } + completedAt?: Date | null; } diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts index b4494e5e5b..e39cb0f1ee 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.spec.ts @@ -70,6 +70,7 @@ describe('CronJobService', () => { ); expect(cronJobRepository.createUnique).toHaveBeenCalledWith({ cronJobType: CronJobType.ProcessPendingIncomingWebhook, + startedAt: expect.any(Date), }); expect(result).toBeInstanceOf(CronJobEntity); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts index b6a1a61e24..d391154a3f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/cron-job/cron-job.service.ts @@ -34,6 +34,7 @@ export class CronJobService { if (!cronJob) { const cronJobEntity = new CronJobEntity(); cronJobEntity.cronJobType = cronJobType; + cronJobEntity.startedAt = new Date(); return this.cronJobRepository.createUnique(cronJobEntity); } cronJob.startedAt = new Date(); diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/fixtures.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/fixtures.ts new file mode 100644 index 0000000000..a153c62cbe --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/fixtures.ts @@ -0,0 +1,34 @@ +import { faker } from '@faker-js/faker'; + +import { KycConfigService } from '../../config/kyc-config.service'; +import { KycEntity } from './kyc.entity'; +import { KycStatus } from './constants'; + +export const mockKycConfigService: Omit = { + apiPrivateKey: faker.string.alphanumeric(), + apiKey: faker.string.alphanumeric(), + baseUrl: faker.internet.url(), +}; + +export function generateKycEntity( + userId: number, + status: KycStatus, +): KycEntity { + const kyc: KycEntity = { + id: faker.number.int(), + userId, + sessionId: faker.string.uuid(), + status, + country: null, + message: null, + url: faker.internet.url(), + createdAt: faker.date.recent(), + updatedAt: new Date(), + }; + + if (kyc.status === KycStatus.APPROVED) { + kyc.country = faker.location.countryCode(); + } + + return kyc; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts index 364055a712..8a2001a167 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts @@ -4,11 +4,7 @@ import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; import { KycStatus } from './constants'; -/** - * TODO: Leave fix follow-up refactoring - * Importing from '../user' causes circular import error here. - */ -import { UserEntity } from '../user/user.entity'; +import type { UserEntity } from '../user'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'kycs' }) export class KycEntity extends BaseEntity { @@ -29,8 +25,8 @@ export class KycEntity extends BaseEntity { message: string | null; @JoinColumn() - @OneToOne(() => UserEntity, (user) => user.kyc) - user: UserEntity; + @OneToOne('UserEntity', (user: UserEntity) => user.kyc) + user?: UserEntity; @Column({ type: 'int' }) userId: number; diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts index d03f281ef1..036698922d 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts @@ -13,12 +13,12 @@ export class KycRepository extends BaseRepository { public async findOneBySessionId( sessionId: string, ): Promise { - const userEntity = await this.findOne({ + const kycEntity = await this.findOne({ where: { sessionId: sessionId, }, }); - return userEntity; + return kycEntity; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.spec.ts index 874bf84d65..23b11e68e3 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.service.spec.ts @@ -1,40 +1,35 @@ import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; -import { ChainId } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; import { Test } from '@nestjs/testing'; import { ethers } from 'ethers'; import { KycConfigService } from '../../config/kyc-config.service'; -import { - Web3ConfigService, - Web3Network, -} from '../../config/web3-config.service'; -import { KycStatus } from '../kyc/constants'; +import { Web3ConfigService } from '../../config/web3-config.service'; + import { generateEthWallet } from '../../../test/fixtures/web3'; import { createHttpServiceMock, createHttpServiceResponse, } from '../../../test/mock-creators/nest'; + +import { KycStatus } from '../kyc/constants'; import { UserEntity } from '../user'; +import { generateWorkerUser } from '../user/fixtures'; +import { mockWeb3ConfigService } from '../web3/fixtures'; import { Web3Service } from '../web3/web3.service'; + import { UpdateKycStatusDto } from './kyc.dto'; import { KycEntity } from './kyc.entity'; import { KycError, KycErrorMessage } from './kyc.error'; import { KycRepository } from './kyc.repository'; import { KycService } from './kyc.service'; +import { generateKycEntity, mockKycConfigService } from './fixtures'; const mockHttpService = createHttpServiceMock(); const mockKycRepository = createMock(); -const mockKycConfigService = { - apiKey: faker.string.alphanumeric(), - baseUrl: faker.internet.url(), -}; - -const operatorWallet = generateEthWallet(); - describe('Kyc Service', () => { let kycService: KycService; let httpService: HttpService; @@ -49,13 +44,7 @@ describe('Kyc Service', () => { { provide: KycRepository, useValue: mockKycRepository }, { provide: Web3ConfigService, - useValue: { - operatorAddress: operatorWallet.address, - privateKey: operatorWallet.privateKey, - network: Web3Network.TESTNET, - reputationNetworkChainId: ChainId.POLYGON_AMOY, - getRpcUrlByChainId: () => faker.internet.url(), - }, + useValue: mockWeb3ConfigService, }, Web3Service, ], @@ -73,14 +62,12 @@ describe('Kyc Service', () => { describe('initSession', () => { describe('Should return existing session url if user already has an active Kyc session, and is waiting for user to make an action', () => { it('status is none', async () => { - const mockUserEntity = { - id: faker.number.int(), - kyc: { - sessionId: faker.string.uuid(), - url: faker.internet.url(), - status: KycStatus.NONE, - }, - }; + const mockUserEntity = generateWorkerUser(); + mockUserEntity.kyc = generateKycEntity( + mockUserEntity.id, + KycStatus.NONE, + ); + const result = await kycService.initSession( mockUserEntity as UserEntity, ); @@ -91,14 +78,11 @@ describe('Kyc Service', () => { }); it('status is resubmission_requested', async () => { - const mockUserEntity = { - id: faker.number.int(), - kyc: { - sessionId: faker.string.uuid(), - url: faker.internet.url(), - status: KycStatus.RESUBMISSION_REQUESTED, - }, - }; + const mockUserEntity = generateWorkerUser(); + mockUserEntity.kyc = generateKycEntity( + mockUserEntity.id, + KycStatus.RESUBMISSION_REQUESTED, + ); const result = await kycService.initSession( mockUserEntity as UserEntity, @@ -111,14 +95,11 @@ describe('Kyc Service', () => { }); it('Should throw an error if user already has an active Kyc session, but is approved already', async () => { - const mockUserEntity = { - id: faker.number.int(), - kyc: { - sessionId: faker.string.uuid(), - url: faker.internet.url(), - status: KycStatus.APPROVED, - }, - }; + const mockUserEntity = generateWorkerUser(); + mockUserEntity.kyc = generateKycEntity( + mockUserEntity.id, + KycStatus.APPROVED, + ); await expect( kycService.initSession(mockUserEntity as any), @@ -128,14 +109,11 @@ describe('Kyc Service', () => { }); it("Should throw an error if user already has an active Kyc session, but it's declined", async () => { - const mockUserEntity = { - id: faker.number.int(), - kyc: { - sessionId: faker.string.uuid(), - url: faker.internet.url(), - status: KycStatus.DECLINED, - }, - }; + const mockUserEntity = generateWorkerUser(); + mockUserEntity.kyc = generateKycEntity( + mockUserEntity.id, + KycStatus.DECLINED, + ); await expect( kycService.initSession(mockUserEntity as any), @@ -145,9 +123,7 @@ describe('Kyc Service', () => { }); it('Should start a Kyc session for the user', async () => { - const mockUserEntity = { - id: faker.number.int(), - }; + const mockUserEntity = generateWorkerUser(); const mockPostKycRespose = { status: 'success', @@ -161,7 +137,7 @@ describe('Kyc Service', () => { ); mockKycRepository.createUnique.mockResolvedValueOnce({} as KycEntity); - const result = await kycService.initSession(mockUserEntity as UserEntity); + const result = await kycService.initSession(mockUserEntity); expect(result).toEqual({ url: mockPostKycRespose.verification.url, @@ -182,6 +158,7 @@ describe('Kyc Service', () => { describe('updateKycStatus', () => { let mockKycUpdate: UpdateKycStatusDto; + beforeEach(() => { mockKycUpdate = { status: 'success', @@ -200,23 +177,23 @@ describe('Kyc Service', () => { it.each([KycStatus.NONE, KycStatus.RESUBMISSION_REQUESTED])( 'Should update the Kyc status of the user [%#]', async (status) => { - const mockKycEntity: Partial = { - status, - }; + const mockKycEntity = generateKycEntity(faker.number.int(), status); mockKycRepository.findOneBySessionId.mockResolvedValueOnce( - mockKycEntity as KycEntity, + mockKycEntity, ); await kycService.updateKycStatus(mockKycUpdate); expect(mockKycRepository.updateOne).toHaveBeenCalledWith({ + ...mockKycEntity, status: KycStatus.APPROVED, country: mockKycUpdate.verification.document.country, message: null, }); }, ); + it.each([ KycStatus.ABANDONED, KycStatus.APPROVED, @@ -225,11 +202,10 @@ describe('Kyc Service', () => { ])( 'Should ignore status update if kyc is already in final status [%#]', async (status) => { - const mockKycEntity: Partial = { - status, - }; + const mockKycEntity = generateKycEntity(faker.number.int(), status); + mockKycRepository.findOneBySessionId.mockResolvedValueOnce( - mockKycEntity as KycEntity, + mockKycEntity, ); await kycService.updateKycStatus(mockKycUpdate); @@ -239,15 +215,13 @@ describe('Kyc Service', () => { ); it('Should throw COUNTRY_NOT_SET error if new status is approved but there is no country', async () => { - const mockKycEntity: Partial = { - userId: faker.number.int(), - status: KycStatus.NONE, - }; - - mockKycRepository.findOneBySessionId.mockResolvedValueOnce( - mockKycEntity as KycEntity, + const mockKycEntity = generateKycEntity( + faker.number.int(), + KycStatus.NONE, ); + mockKycRepository.findOneBySessionId.mockResolvedValueOnce(mockKycEntity); + mockKycUpdate.verification.document.country = null; expect(kycService.updateKycStatus(mockKycUpdate)).rejects.toThrow( new KycError( @@ -260,7 +234,7 @@ describe('Kyc Service', () => { describe('getSignedAddress', () => { it('Should throw an error if the user has no wallet address registered', async () => { - const mockUserEntity = { id: faker.number.int() }; + const mockUserEntity = generateWorkerUser(); await expect( kycService.getSignedAddress(mockUserEntity as UserEntity), @@ -273,13 +247,10 @@ describe('Kyc Service', () => { }); it('Should throw an error if the user KYC status is not approved', async () => { - const mockUserEntity = { - id: faker.number.int(), - evmAddress: faker.finance.ethereumAddress(), - kyc: { - status: KycStatus.NONE, - }, - }; + const mockUserEntity = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + mockUserEntity.kyc = generateKycEntity(mockUserEntity.id, KycStatus.NONE); await expect( kycService.getSignedAddress(mockUserEntity as UserEntity), @@ -289,24 +260,25 @@ describe('Kyc Service', () => { }); it('Should return the signed address', async () => { - const mockUserEntity = { - evmAddress: faker.finance.ethereumAddress(), - kyc: { - status: KycStatus.APPROVED, - }, - }; + const mockUserEntity = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + mockUserEntity.kyc = generateKycEntity( + mockUserEntity.id, + KycStatus.APPROVED, + ); const result = await kycService.getSignedAddress( mockUserEntity as UserEntity, ); - const wallet = new ethers.Wallet(operatorWallet.privateKey); + const wallet = new ethers.Wallet(mockWeb3ConfigService.privateKey); const signedUserAddressWithOperatorPrivateKey = await wallet.signMessage( mockUserEntity.evmAddress, ); expect(result).toEqual({ - key: `KYC-${operatorWallet.address}`, + key: `KYC-${mockWeb3ConfigService.operatorAddress}`, value: signedUserAddressWithOperatorPrivateKey, }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/fixtures.ts b/packages/apps/reputation-oracle/server/src/modules/nda/fixtures.ts new file mode 100644 index 0000000000..17f125fc2f --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/nda/fixtures.ts @@ -0,0 +1,7 @@ +import { faker } from '@faker-js/faker'; + +import { NDAConfigService } from '../../config/nda-config.service'; + +export const mockNdaConfigService: Omit = { + latestNdaUrl: faker.internet.url(), +}; diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts index f01a8edd78..1c35f41141 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.service.spec.ts @@ -1,18 +1,18 @@ -import { faker } from '@faker-js/faker/.'; +import { createMock } from '@golevelup/ts-jest'; +import { faker } from '@faker-js/faker'; import { Test, TestingModule } from '@nestjs/testing'; -import { UserEntity, UserRepository } from '../user'; import { NDAConfigService } from '../../config/nda-config.service'; + +import { generateWorkerUser } from '../user/fixtures'; +import { UserEntity, UserRepository } from '../user'; + import { NDASignatureDto } from './nda.dto'; import { NDAError, NDAErrorMessage } from './nda.error'; import { NDAService } from './nda.service'; +import { mockNdaConfigService } from './fixtures'; -const mockUserRepository = { - updateOne: jest.fn(), -}; -const mockNdaConfigService = { - latestNdaUrl: faker.internet.url(), -}; +const mockUserRepository = createMock(); describe('NDAService', () => { let service: NDAService; @@ -35,28 +35,20 @@ describe('NDAService', () => { describe('signNDA', () => { it('should sign the NDA if the URL is valid and the user has not signed it yet', async () => { - const user = { - id: faker.number.int(), - email: faker.internet.email(), - ndaSignedUrl: undefined, - }; + const user = generateWorkerUser(); const nda: NDASignatureDto = { url: mockNdaConfigService.latestNdaUrl, }; - await service.signNDA(user as UserEntity, nda); + await service.signNDA(user, nda); expect(user.ndaSignedUrl).toBe(mockNdaConfigService.latestNdaUrl); expect(mockUserRepository.updateOne).toHaveBeenCalledWith(user); }); it('should throw an error if the NDA URL is invalid', async () => { - const user = { - id: faker.number.int(), - email: faker.internet.email(), - ndaSignedUrl: undefined, - }; + const user = generateWorkerUser(); const invalidNda: NDASignatureDto = { url: faker.internet.url(), @@ -68,11 +60,8 @@ describe('NDAService', () => { }); it('should return ok if the user has already signed the NDA', async () => { - const user = { - id: faker.number.int(), - email: faker.internet.email(), - ndaSignedUrl: mockNdaConfigService.latestNdaUrl, - }; + const user = generateWorkerUser(); + user.ndaSignedUrl = mockNdaConfigService.latestNdaUrl; const nda: NDASignatureDto = { url: mockNdaConfigService.latestNdaUrl, diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts index 0c0f793b9a..47239836b7 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.entity.ts @@ -1,7 +1,7 @@ import { Column, Entity, Index, OneToMany } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; -import { UserQualificationEntity } from './user-qualification.entity'; +import type { UserQualificationEntity } from './user-qualification.entity'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'qualifications' }) @Index(['reference'], { unique: true }) @@ -19,8 +19,9 @@ export class QualificationEntity extends BaseEntity { public expiresAt?: Date | null; @OneToMany( - () => UserQualificationEntity, - (userQualification) => userQualification.qualification, + 'UserQualificationEntity', + (userQualification: UserQualificationEntity) => + userQualification.qualification, ) public userQualifications: UserQualificationEntity[]; } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts index a0a2907103..5ef3464f0b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.repository.ts @@ -39,6 +39,7 @@ export class QualificationRepository extends BaseRepository async saveUserQualifications( userQualifications: UserQualificationEntity[], ): Promise { + // TODO: use base repository method for that await this.dataSource .getRepository(UserQualificationEntity) .save(userQualifications); diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts index 36597690fc..ed67d87cde 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.spec.ts @@ -9,7 +9,6 @@ import { } from './qualification.error'; import { CreateQualificationDto } from './qualification.dto'; import { QualificationEntity } from './qualification.entity'; -import { UserEntity } from '../user/user.entity'; import { UserQualificationEntity } from './user-qualification.entity'; import { ServerConfigService } from '../../config/server-config.service'; import { ConfigService } from '@nestjs/config'; @@ -238,7 +237,7 @@ describe('QualificationService', () => { qualificationRepository.findByReference = jest .fn() .mockResolvedValue(qualificationEntity); - qualificationService.getWorkers = jest + userRepository.findWorkersByAddresses = jest .fn() .mockResolvedValue([{ id: 1 }]); @@ -261,7 +260,7 @@ describe('QualificationService', () => { qualificationRepository.findByReference = jest .fn() .mockResolvedValue(qualificationEntity); - qualificationService.getWorkers = jest + userRepository.findWorkersByAddresses = jest .fn() .mockResolvedValue([{ id: 1 }]); @@ -301,7 +300,7 @@ describe('QualificationService', () => { qualificationRepository.findByReference = jest .fn() .mockResolvedValue(qualificationEntity); - qualificationService.getWorkers = jest + userRepository.findWorkersByAddresses = jest .fn() .mockResolvedValue([{ id: 1 }]); @@ -326,7 +325,7 @@ describe('QualificationService', () => { qualificationRepository.findByReference = jest .fn() .mockResolvedValue(qualificationEntity); - qualificationService.getWorkers = jest + userRepository.findWorkersByAddresses = jest .fn() .mockResolvedValue([{ id: 1 }]); @@ -347,17 +346,4 @@ describe('QualificationService', () => { ); }); }); - - describe('getWorkers', () => { - it('should return workers by addresses', async () => { - const addresses = ['address1']; - const users = [{ id: 1 } as UserEntity]; - - userRepository.findByAddress = jest.fn().mockResolvedValue(users); - - const result = await qualificationService.getWorkers(addresses); - - expect(result).toEqual(users); - }); - }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts index a05ffce384..ee12c178ba 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/qualification.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { CreateQualificationDto, QualificationDto } from './qualification.dto'; import { QualificationEntity } from './qualification.entity'; import { QualificationRepository } from './qualification.repository'; -import { UserEntity, UserRepository, UserStatus, UserRole } from '../user'; +import { UserRepository, UserStatus } from '../user'; import { UserQualificationEntity } from './user-qualification.entity'; import { ServerConfigService } from '../../config/server-config.service'; import { @@ -23,7 +23,7 @@ export class QualificationService { private readonly serverConfigService: ServerConfigService, ) {} - public async createQualification( + async createQualification( createQualificationDto: CreateQualificationDto, ): Promise { const newQualification = new QualificationEntity(); @@ -67,7 +67,7 @@ export class QualificationService { }; } - public async getQualifications(): Promise { + async getQualifications(): Promise { try { const qualificationEntities = await this.qualificationRepository.getQualifications(); @@ -107,10 +107,7 @@ export class QualificationService { await this.qualificationRepository.deleteOne(qualificationEntity); } - public async assign( - reference: string, - workerAddresses: string[], - ): Promise { + async assign(reference: string, workerAddresses: string[]): Promise { const qualificationEntity = await this.qualificationRepository.findByReference(reference); @@ -121,7 +118,8 @@ export class QualificationService { ); } - const users = await this.getWorkers(workerAddresses); + const users = + await this.userRepository.findWorkersByAddresses(workerAddresses); if (users.length === 0) { throw new QualificationError( @@ -131,16 +129,33 @@ export class QualificationService { } const newUserQualifications = users - .filter( - (user) => - !qualificationEntity.userQualifications.some( + .filter((user) => { + if (user.status !== UserStatus.ACTIVE) { + return false; + } + + const hasDesiredQualification = + qualificationEntity.userQualifications.some( (uq) => uq.user.id === user.id, - ), - ) + ); + if (hasDesiredQualification) { + return false; + } + + return true; + }) .map((user) => { const userQualification = new UserQualificationEntity(); userQualification.user = user; userQualification.qualification = qualificationEntity; + + /** + * TODO: remove this when using base repository + */ + const date = new Date(); + userQualification.createdAt = date; + userQualification.updatedAt = date; + return userQualification; }); @@ -149,10 +164,7 @@ export class QualificationService { ); } - public async unassign( - reference: string, - workerAddresses: string[], - ): Promise { + async unassign(reference: string, workerAddresses: string[]): Promise { const qualificationEntity = await this.qualificationRepository.findByReference(reference); @@ -163,7 +175,8 @@ export class QualificationService { ); } - const users = await this.getWorkers(workerAddresses); + const users = + await this.userRepository.findWorkersByAddresses(workerAddresses); if (users.length === 0) { throw new QualificationError( @@ -177,15 +190,4 @@ export class QualificationService { qualificationEntity, ); } - - // TODO: Move this method to the `user` module. - public async getWorkers(addresses: string[]): Promise { - const users = await this.userRepository.findByAddress( - addresses, - UserRole.WORKER, - UserStatus.ACTIVE, - ); - - return users; - } } diff --git a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts index 2248fcdd80..bb2631273a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/qualification/user-qualification.entity.ts @@ -1,21 +1,17 @@ import { Entity, ManyToOne } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; -/** - * TODO: Leave fix follow-up refactoring - * Importing from '../user' causes circular import error here. - */ -import { UserEntity } from '../user/user.entity'; -import { QualificationEntity } from '../qualification/qualification.entity'; +import type { UserEntity } from '../user'; +import type { QualificationEntity } from '../qualification/qualification.entity'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'user_qualifications' }) export class UserQualificationEntity extends BaseEntity { - @ManyToOne(() => UserEntity, (user) => user.userQualifications) + @ManyToOne('UserEntity', (user: UserEntity) => user.userQualifications) public user: UserEntity; @ManyToOne( - () => QualificationEntity, - (qualification) => qualification.userQualifications, + 'QualificationEntity', + (qualification: QualificationEntity) => qualification.userQualifications, ) public qualification: QualificationEntity; } diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts index 48ad11d0de..a46a59e3f4 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.dto.ts @@ -95,7 +95,6 @@ export class ReputationGetAllQueryDto { export class ReputationGetParamsDto { @ApiProperty() - @IsString() @IsEthereumAddress() public address: string; } @@ -112,7 +111,6 @@ export class ReputationDto { chainId: ChainId; @ApiProperty() - @IsString() @IsEthereumAddress() address: string; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/index.ts new file mode 100644 index 0000000000..62ed5d2312 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/index.ts @@ -0,0 +1,2 @@ +export * from './sitekey'; +export * from './user'; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/fixtures/sitekey.ts b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/sitekey.ts new file mode 100644 index 0000000000..5c404f14b9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/sitekey.ts @@ -0,0 +1,29 @@ +import { faker } from '@faker-js/faker'; + +import { generateEthWallet } from '../../../../test/fixtures/web3'; + +import { SiteKeyEntity, SiteKeyType } from '../site-key.entity'; + +export function generateSiteKeyEntity( + userId: number, + type: SiteKeyType, +): SiteKeyEntity { + let siteKey: string; + switch (type) { + case SiteKeyType.HCAPTCHA: + siteKey = faker.string.uuid(); + break; + case SiteKeyType.REGISTRATION: + siteKey = generateEthWallet().address; + break; + } + + return { + id: faker.number.int(), + userId, + type, + siteKey, + createdAt: faker.date.recent(), + updatedAt: new Date(), + }; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts new file mode 100644 index 0000000000..95d4f1c054 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts @@ -0,0 +1,73 @@ +import { faker } from '@faker-js/faker'; +import { SetNonNullable } from 'type-fest'; + +import { generateEthWallet } from '../../../../test/fixtures/web3'; + +import * as securityUtils from '../../../utils/security'; +import * as web3Utils from '../../../utils/web3'; + +import { OperatorUserEntity, Web2UserEntity } from '../types'; +import { Role, UserStatus } from '../user.entity'; + +type Web2UserWithAddress = SetNonNullable; + +type GeneratedUser = T extends { privateKey: string } + ? Web2UserWithAddress + : Web2UserEntity; + +type GenerateUserOptions = { + privateKey?: string; + status?: UserStatus; +}; +export function generateWorkerUser( + options?: T, +): GeneratedUser { + const password = faker.internet.password(); + const passwordHash = securityUtils.hashPassword(password); + + const generatedUser: Web2UserEntity | Web2UserWithAddress = { + id: faker.number.int(), + email: faker.internet.email(), + password: passwordHash, + ndaSignedUrl: null, + role: Role.WORKER, + evmAddress: null, + status: options?.status || UserStatus.ACTIVE, + createdAt: faker.date.recent(), + updatedAt: new Date(), + + nonce: null, + }; + + if (options?.privateKey) { + generatedUser.evmAddress = generateEthWallet( + options.privateKey, + ).address.toLowerCase(); + } + + return generatedUser as GeneratedUser; +} + +type GenerateOperatorOptions = { + privateKey?: string; + status?: UserStatus; +}; +export function generateOperator( + options: GenerateOperatorOptions = {}, +): OperatorUserEntity { + const user: OperatorUserEntity = { + id: faker.number.int(), + role: Role.OPERATOR, + evmAddress: generateEthWallet(options?.privateKey).address.toLowerCase(), + status: options?.status || UserStatus.ACTIVE, + nonce: web3Utils.generateNonce(), + createdAt: faker.date.recent(), + updatedAt: new Date(), + + email: null, + password: null, + ndaSignedUrl: null, + }; + + return user; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/user/index.ts b/packages/apps/reputation-oracle/server/src/modules/user/index.ts index c12012b2b1..ce8027f924 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/index.ts @@ -1,4 +1,6 @@ +export { SiteKeyType } from './site-key.entity'; +export type * from './types'; export { UserEntity, UserStatus, Role as UserRole } from './user.entity'; -export { UserRepository } from './user.repository'; -export { UserService } from './user.service'; export { UserModule } from './user.module'; +export { UserRepository } from './user.repository'; +export { UserService, OperatorStatus } from './user.service'; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts index 2dad37348c..b1daaecc69 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts @@ -1,29 +1,31 @@ import { Entity, Column, JoinColumn, ManyToOne, Unique } from 'typeorm'; + import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; -/** - * TODO: Leave fix follow-up refactoring - * Importing from '../user' causes circular import error here. - */ -import { UserEntity } from './user.entity'; -import { SiteKeyType } from '../../common/enums'; -// TypeORM doesn't natively support partial unique indices. -// To ensure a user can only have one record in the site_keys table with the type 'hcaptcha', -// we enforce a partial unique index at the database level. +import type { UserEntity } from './user.entity'; + +export enum SiteKeyType { + HCAPTCHA = 'hcaptcha', + REGISTRATION = 'registration', +} + @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'site_keys' }) @Unique(['siteKey', 'type', 'user']) export class SiteKeyEntity extends BaseEntity { @Column({ type: 'varchar' }) - public siteKey: string; + siteKey: string; @Column({ type: 'enum', enum: SiteKeyType, }) - public type: SiteKeyType; + type: SiteKeyType; - @ManyToOne(() => UserEntity, (user) => user.siteKeys) + @ManyToOne('UserEntity', (user: UserEntity) => user.siteKeys) @JoinColumn() - public user: UserEntity; + user?: UserEntity; + + @Column({ type: 'int' }) + userId: number; } diff --git a/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts b/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts index a51e388862..d951a16b5f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts @@ -1,30 +1,30 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; + import { BaseRepository } from '../../database/base.repository'; -import { SiteKeyEntity } from './site-key.entity'; -import { SiteKeyType } from '../../common/enums'; -import { UserEntity } from './user.entity'; + +import { SiteKeyEntity, SiteKeyType } from './site-key.entity'; @Injectable() export class SiteKeyRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor(dataSource: DataSource) { super(SiteKeyEntity, dataSource); } async findByUserSiteKeyAndType( - user: UserEntity, + userId: number, siteKey: string, type: SiteKeyType, ): Promise { return this.findOne({ - where: { user, siteKey, type }, + where: { userId, siteKey, type }, }); } async findByUserAndType( - user: UserEntity, + userId: number, type: SiteKeyType, ): Promise { - return this.find({ where: { user, type } }); + return this.find({ where: { userId, type } }); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/user/types.ts b/packages/apps/reputation-oracle/server/src/modules/user/types.ts new file mode 100644 index 0000000000..0f64c88b47 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/user/types.ts @@ -0,0 +1,21 @@ +import { SetNonNullable } from 'type-fest'; + +import { UserEntity } from './user.entity'; + +/** + * ATM UserEntity is used to store two different domain objects + * that have intersection in properties: + * = Operator - aka "Oracle"; it should be authorized using web3 signature + * and always has evmAddres, but never has email, password and some other fields + * - Web2User - is "Worker", admin user and HUMAN App; they always + * have email & password, but might not have evmAddress + * + * Until we split the DB model - we differentiate them in code using different types. + */ + +export type Web2UserEntity = SetNonNullable; + +export type OperatorUserEntity = SetNonNullable< + UserEntity, + 'evmAddress' | 'nonce' +>; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts index a3eb05498b..8a516d9924 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts @@ -16,12 +16,14 @@ import { Get, UseFilters, } from '@nestjs/common'; + import { Public } from '../../common/decorators'; import { SignatureType } from '../../common/enums/web3'; import { RequestWithUser } from '../../common/interfaces/request'; import { Web3ConfigService } from '../../config/web3-config.service'; import { HCaptchaGuard } from '../../integrations/hcaptcha/hcaptcha.guard'; -import { prepareSignatureBody } from '../../utils/web3'; +import * as web3Utils from '../../utils/web3'; + import { DisableOperatorDto, PrepareSignatureDto, @@ -30,12 +32,12 @@ import { RegisterLabelerResponseDto, EnableOperatorDto, RegistrationInExchangeOracleDto, - RegistrationInExchangeOraclesDto, + RegistrationInExchangeOraclesResponseDto, RegistrationInExchangeOracleResponseDto, } from './user.dto'; -import { UserService } from './user.service'; -import { UserRepository } from './user.repository'; import { UserErrorFilter } from './user.error.filter'; +import { UserRepository } from './user.repository'; +import { UserService } from './user.service'; /** * TODO: @@ -94,7 +96,11 @@ export class UserController { @Req() request: RequestWithUser, @Body() data: RegisterAddressRequestDto, ): Promise { - await this.userService.registerAddress(request.user, data); + await this.userService.registerAddress( + request.user, + data.address, + data.signature, + ); } @ApiOperation({ @@ -150,12 +156,13 @@ export class UserController { async prepareSignature( @Body() data: PrepareSignatureDto, ): Promise { - let nonce; + let nonce: string | undefined; if (data.type === SignatureType.SIGNIN) { - nonce = (await this.userRepository.findOneByAddress(data.address))?.nonce; + const user = await this.userService.findOperatorUser(data.address); + nonce = user?.nonce; } - const preparedSignatureBody = await prepareSignatureBody({ + const preparedSignatureBody = await web3Utils.prepareSignatureBody({ from: data.address, to: this.web3ConfigService.operatorAddress, contents: data.type, @@ -199,7 +206,7 @@ export class UserController { @ApiResponse({ status: 200, description: 'List of registered oracles retrieved successfully', - type: RegistrationInExchangeOraclesDto, + type: RegistrationInExchangeOraclesResponseDto, }) @ApiResponse({ status: 401, @@ -208,7 +215,7 @@ export class UserController { @Get('/exchange-oracle-registration') async getRegistrationInExchangeOracles( @Req() request: RequestWithUser, - ): Promise { + ): Promise { const oracleAddresses = await this.userService.getRegistrationInExchangeOracles(request.user); diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.dto.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.dto.ts index 44f4a12be4..53c389b32a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.dto.ts @@ -1,68 +1,68 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEthereumAddress, IsOptional, IsString } from 'class-validator'; + import { SignatureType } from '../../common/enums/web3'; -import { IsLowercasedEnum } from '../../common/validators'; +import { + IsLowercasedEnum, + IsValidWeb3Signature, +} from '../../common/validators'; export class RegisterLabelerResponseDto { @ApiProperty({ name: 'site_key' }) - @IsString() - public siteKey: string; + siteKey: string; } export class RegisterAddressRequestDto { @ApiProperty() - @IsString() - public address: string; + @IsEthereumAddress() + address: string; @ApiProperty() - @IsString() - public signature: string; + @IsValidWeb3Signature() + signature: string; } export class EnableOperatorDto { @ApiProperty() - @IsString() - public signature: string; + @IsValidWeb3Signature() + signature: string; } export class DisableOperatorDto { @ApiProperty() - @IsString() - public signature: string; + @IsValidWeb3Signature() + signature: string; } export class SignatureBodyDto { @ApiProperty() - @IsString() @IsEthereumAddress() - public from: string; + from: string; @ApiProperty() - @IsString() @IsEthereumAddress() - public to: string; + to: string; @ApiProperty() @IsString() - public contents: string; + contents: string; @ApiProperty() @IsOptional() @IsString() - public nonce?: string | undefined; + nonce?: string | undefined; } export class PrepareSignatureDto { @ApiProperty() - @IsString() @IsEthereumAddress() - public address: string; + address: string; @ApiProperty({ enum: SignatureType, }) @IsLowercasedEnum(SignatureType) - public type: SignatureType; + type: SignatureType; } export class RegistrationInExchangeOracleDto { @@ -70,20 +70,22 @@ export class RegistrationInExchangeOracleDto { name: 'oracle_address', description: 'Ethereum address of the oracle', }) - @IsEthereumAddress() - public oracleAddress: string; + @IsEthereumAddress({ + message: 'oracle_address must be an Ethereum address', + }) + oracleAddress: string; @ApiProperty({ name: 'h_captcha_token' }) @IsString() - public hCaptchaToken: string; + hCaptchaToken: string; } export class RegistrationInExchangeOracleResponseDto { @ApiProperty({ name: 'oracle_address' }) - public oracleAddress: string; + oracleAddress: string; } -export class RegistrationInExchangeOraclesDto { +export class RegistrationInExchangeOraclesResponseDto { @ApiProperty({ name: 'oracle_addresses' }) - public oracleAddresses: string[]; + oracleAddresses: string[]; } diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts index 07cf52a5d6..3b6a75f1ab 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.entity.ts @@ -3,10 +3,11 @@ import { Column, Entity, OneToMany, OneToOne } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; import { BaseEntity } from '../../database/base.entity'; -import { TokenEntity } from '../auth/token.entity'; -import { KycEntity } from '../kyc/kyc.entity'; -import { SiteKeyEntity } from './site-key.entity'; -import { UserQualificationEntity } from '../qualification/user-qualification.entity'; + +import type { KycEntity } from '../kyc/kyc.entity'; +import type { UserQualificationEntity } from '../qualification/user-qualification.entity'; + +import type { SiteKeyEntity } from './site-key.entity'; export enum UserStatus { ACTIVE = 'active', @@ -24,42 +25,60 @@ export enum Role { @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'users' }) export class UserEntity extends BaseEntity { @Exclude() - @Column({ type: 'varchar', nullable: true }) - public password: string; + @Column({ + type: 'varchar', + nullable: true, + }) + password: string | null; - @Column({ type: 'varchar', nullable: true, unique: true }) - public email: string; + @Column({ + type: 'varchar', + nullable: true, + unique: true, + }) + email: string | null; - @Column({ type: 'enum', enum: Role }) - public role: Role; + @Column({ + type: 'enum', + enum: Role, + }) + role: Role; - @Column({ type: 'varchar', nullable: true, unique: true }) - public evmAddress: string; + @Column({ + type: 'varchar', + nullable: true, + unique: true, + }) + evmAddress: string | null; - @Column({ type: 'varchar', nullable: true }) - public nonce: string; + @Exclude() + @Column({ + type: 'varchar', + nullable: true, + }) + nonce: string | null; @Column({ type: 'enum', enum: UserStatus, }) - public status: UserStatus; - - @OneToOne(() => TokenEntity) - public token: TokenEntity; + status: UserStatus; - @OneToOne(() => KycEntity, (kyc) => kyc.user) - public kyc?: KycEntity; + @OneToOne('KycEntity', (kyc: KycEntity) => kyc.user) + kyc?: KycEntity; - @OneToMany(() => SiteKeyEntity, (siteKey) => siteKey.user) - public siteKeys?: SiteKeyEntity[]; + @OneToMany('SiteKeyEntity', (siteKey: SiteKeyEntity) => siteKey.user) + siteKeys?: SiteKeyEntity[]; @OneToMany( - () => UserQualificationEntity, - (userQualification) => userQualification.user, + 'UserQualificationEntity', + (userQualification: UserQualificationEntity) => userQualification.user, ) - public userQualifications: UserQualificationEntity[]; + userQualifications?: UserQualificationEntity[]; - @Column({ type: 'varchar', nullable: true }) - public ndaSignedUrl?: string; + @Column({ + type: 'varchar', + nullable: true, + }) + ndaSignedUrl: string | null; } diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.error.filter.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.error.filter.ts index 1131671e47..378df84c65 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.error.filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.error.filter.ts @@ -6,12 +6,13 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; +import logger from '../../logger'; + import { UserError, DuplicatedWalletAddressError, InvalidWeb3SignatureError, } from './user.error'; -import logger from '../../logger'; type UserControllerError = | UserError diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts index c5fbd13bae..fb38018770 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { HCaptchaModule } from '../../integrations/hcaptcha/hcaptcha.module'; + import { Web3Module } from '../web3/web3.module'; -import { UserService } from './user.service'; -import { UserRepository } from './user.repository'; -import { UserController } from './user.controller'; import { SiteKeyRepository } from './site-key.repository'; +import { UserController } from './user.controller'; +import { UserRepository } from './user.repository'; +import { UserService } from './user.service'; @Module({ imports: [Web3Module, HCaptchaModule], diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts index 6abb316963..2921ad4193 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts @@ -1,7 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, FindManyOptions, In } from 'typeorm'; + import { BaseRepository } from '../../database/base.repository'; -import { Role, UserStatus, UserEntity } from './user.entity'; + +import { Role, UserEntity } from './user.entity'; + +type FindOptions = { + relations?: FindManyOptions['relations']; +}; @Injectable() export class UserRepository extends BaseRepository { @@ -9,74 +15,48 @@ export class UserRepository extends BaseRepository { super(UserEntity, dataSource); } - async findById(id: number): Promise { + async findOneById( + id: number, + options: FindOptions = {}, + ): Promise { return this.findOne({ where: { id }, - relations: { kyc: true, siteKeys: true }, + relations: options.relations, }); } - async findOneByEmail(email: string): Promise { + async findOneByEmail( + email: string, + options: FindOptions = {}, + ): Promise { return this.findOne({ where: { email }, - relations: { - kyc: true, - siteKeys: true, - userQualifications: { - qualification: true, - }, - }, + relations: options.relations, }); } - async findOneByAddress(address: string): Promise { + async findOneByAddress( + address: string, + options: FindOptions = {}, + ): Promise { return this.findOne({ - where: { evmAddress: address.toLowerCase() }, - relations: { kyc: true, siteKeys: true }, - }); - } - - async findByEmail( - emails: string[], - role?: Role, - status?: UserStatus, - ): Promise { - const whereConditions = emails.map((email) => { - const condition: any = { email }; - if (role) { - condition.role = role; - } - if (status) { - condition.status = status; - } - return condition; - }); - - return this.find({ - where: whereConditions, - relations: { kyc: true, siteKeys: true }, + where: { + evmAddress: address.toLowerCase(), + }, + relations: options.relations, }); } - async findByAddress( - addresses: string[], - role?: Role, - status?: UserStatus, - ): Promise { - const whereConditions = addresses.map((address) => { - const condition: any = { evmAddress: address.toLowerCase() }; - if (role) { - condition.role = role; - } - if (status) { - condition.status = status; - } - return condition; - }); + async findWorkersByAddresses(addresses: string[]): Promise { + const lowercasedAddresses = addresses.map((address) => + address.toLowerCase(), + ); return this.find({ - where: whereConditions, - relations: { kyc: true, siteKeys: true }, + where: { + role: Role.WORKER, + evmAddress: In(lowercasedAddresses), + }, }); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts index 2b472d89ba..d0e679cb42 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts @@ -1,810 +1,650 @@ -import { createMock } from '@golevelup/ts-jest'; -import { ChainId, KVStoreClient, KVStoreUtils } from '@human-protocol/sdk'; +jest.mock('@human-protocol/sdk'); -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; +import { faker } from '@faker-js/faker'; +import { createMock } from '@golevelup/ts-jest'; +import { KVStoreClient, KVStoreUtils } from '@human-protocol/sdk'; import { Test } from '@nestjs/testing'; -import { DeepPartial } from 'typeorm'; -import { UserRepository } from './user.repository'; -import { UserService } from './user.service'; -import { RegistrationInExchangeOracleDto } from './user.dto'; -import { UserStatus, Role, UserEntity } from './user.entity'; -import { OperatorStatus } from '../../common/enums/user'; -import { KycStatus } from '../kyc/constants'; -import { signMessage, prepareSignatureBody } from '../../utils/web3'; -import { - MOCK_ADDRESS, - MOCK_EMAIL, - MOCK_PRIVATE_KEY, -} from '../../../test/constants'; -import { Web3Service } from '../web3/web3.service'; -import { SignatureBodyDto } from '../user/user.dto'; +import { generateEthWallet } from '../../../test/fixtures/web3'; + import { SignatureType } from '../../common/enums/web3'; import { Web3ConfigService } from '../../config/web3-config.service'; -import { SiteKeyRepository } from './site-key.repository'; -import { SiteKeyEntity } from './site-key.entity'; import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; -import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; +import * as securityUtils from '../../utils/security'; +import * as web3Utils from '../../utils/web3'; + +import { KycStatus } from '../kyc/constants'; +import { generateKycEntity } from '../kyc/fixtures'; +import { mockWeb3ConfigService } from '../web3/fixtures'; +import { Web3Service } from '../web3/web3.service'; + +import { + generateSiteKeyEntity, + generateOperator, + generateWorkerUser, +} from './fixtures'; +import { SiteKeyRepository } from './site-key.repository'; +import { SiteKeyType } from './site-key.entity'; +import { Role, UserStatus } from './user.entity'; import { - UserError, - UserErrorMessage, DuplicatedWalletAddressError, InvalidWeb3SignatureError, -} from '../../modules/user/user.error'; -import { SiteKeyType } from '../../common/enums'; - -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - KVStoreClient: { - build: jest.fn().mockImplementation(() => ({ - set: jest.fn(), - })), - }, - KVStoreUtils: { - get: jest.fn(), - }, -})); + UserError, + UserErrorMessage, +} from './user.error'; +import { UserRepository } from './user.repository'; +import { UserService, OperatorStatus } from './user.service'; + +const mockUserRepository = createMock(); +const mockSiteKeyRepository = createMock(); +const mockHCaptchaService = createMock(); + +const mockedKVStoreClient = jest.mocked(KVStoreClient); +const mockedKVStoreUtils = jest.mocked(KVStoreUtils); describe('UserService', () => { let userService: UserService; - let userRepository: UserRepository; - let web3Service: Web3Service; - let hcaptchaService: HCaptchaService; - let siteKeyRepository: SiteKeyRepository; - - beforeEach(async () => { - const signerMock = { - address: MOCK_ADDRESS, - getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }), - }; + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ UserService, - HCaptchaService, - { provide: UserRepository, useValue: createMock() }, { - provide: SiteKeyRepository, - useValue: createMock(), + provide: UserRepository, + useValue: mockUserRepository, }, { provide: SiteKeyRepository, - useValue: createMock(), + useValue: mockSiteKeyRepository, }, { - provide: Web3Service, - useValue: { - getSigner: jest.fn().mockReturnValue(signerMock), - }, + provide: SiteKeyRepository, + useValue: mockSiteKeyRepository, }, + Web3Service, { - provide: HttpService, - useValue: createMock(), + provide: HCaptchaService, + useValue: mockHCaptchaService, }, - ConfigService, { provide: Web3ConfigService, - useValue: { - operatorAddress: MOCK_ADDRESS, - reputationNetworkChainId: ChainId.POLYGON_AMOY, - }, + useValue: mockWeb3ConfigService, }, - HCaptchaConfigService, ], }).compile(); userService = moduleRef.get(UserService); - userRepository = moduleRef.get(UserRepository); - web3Service = moduleRef.get(Web3Service); - hcaptchaService = moduleRef.get(HCaptchaService); - siteKeyRepository = moduleRef.get(SiteKeyRepository); }); - describe('checkPasswordMatchesHash', () => { - const password = 'password123'; - const hashedPassword = - '$2b$12$Z02o9/Ay7CT0n99icApZYORH8iJI9VGtl3mju7d0c4SdDDujhSzOa'; - - it('should return true if password matches', () => { - const result = UserService.checkPasswordMatchesHash( - password, - hashedPassword, - ); - - expect(result).toBe(true); - }); - - it('should return false if password does not match', () => { - const result = UserService.checkPasswordMatchesHash( - password, - '321drowssap', - ); + afterEach(() => { + jest.resetAllMocks(); + }); - expect(result).toBe(false); + describe('isWeb2UserRole', () => { + it.each( + Object.values(Role).map((role) => ({ + role, + result: role === Role.OPERATOR ? false : true, + })), + )('should return "$result" for "$role" role', ({ role, result }) => { + expect(UserService.isWeb2UserRole(role)).toBe(result); }); }); - describe('create', () => { - it('should create a new user and return the created user entity', async () => { + describe('createWorkerUser', () => { + it('should create worker user and return the created entity', async () => { const createUserData = { - email: 'test@example.com', - password: 'password123', - }; - const createdUser: Partial = { - email: createUserData.email, - password: expect.any(String), - role: Role.WORKER, - status: UserStatus.PENDING, + email: faker.internet.email(), + password: faker.internet.password(), }; - const result = await userService.create(createUserData); - expect(userRepository.createUnique).toHaveBeenCalledWith({ + const expectedUserData = { email: createUserData.email, - password: expect.any(String), role: Role.WORKER, status: UserStatus.PENDING, - }); - expect(result).toMatchObject(createdUser); - }); - }); - - describe('registerLabeler', () => { - it('should register labeler successfully and return site key', async () => { - const userEntity: DeepPartial = { - id: 1, - email: MOCK_EMAIL, - evmAddress: MOCK_ADDRESS, - role: Role.WORKER, - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, + password: expect.not.stringMatching(createUserData.password), }; - const mockLabelerData = { sitekeys: [{ sitekey: 'site_key' }] }; - - hcaptchaService.registerLabeler = jest.fn().mockResolvedValueOnce(true); - hcaptchaService.getLabelerData = jest - .fn() - .mockResolvedValueOnce(mockLabelerData); - - web3Service.getSigner = jest.fn().mockReturnValue({ - signMessage: jest.fn().mockResolvedValue('site_key'), - }); + const result = await userService.createWorkerUser(createUserData); - const result = await userService.registerLabeler( - userEntity as UserEntity, + expect(mockUserRepository.createUnique).toHaveBeenCalledWith( + expectedUserData, ); + expect(result).toEqual(expectedUserData); - expect(result).toEqual('site_key'); + expect( + securityUtils.comparePasswordWithHash( + createUserData.password, + result.password, + ), + ).toBe(true); }); + }); - it('should throw InvalidType if user type is invalid', async () => { - const userEntity: DeepPartial = { - id: 1, - email: MOCK_EMAIL, - evmAddress: MOCK_ADDRESS, - role: Role.OPERATOR, // Invalid type - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, - }; + describe('updatePassword', () => { + it('should throw if user not found', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce(null); await expect( - userService.registerLabeler(userEntity as UserEntity), - ).rejects.toThrow( - new UserError(UserErrorMessage.INVALID_ROLE, userEntity.id as number), - ); + userService.updatePassword( + faker.number.int(), + faker.internet.password(), + ), + ).rejects.toThrow('User not found'); + + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); }); - it('should throw KycNotApproved if user KYC status is not approved', async () => { - const userEntity: DeepPartial = { - id: 1, - email: MOCK_EMAIL, - evmAddress: MOCK_ADDRESS, - role: Role.WORKER, - kyc: { - country: 'FR', - status: KycStatus.REVIEW, - }, - }; + it('should throw if not web2 user', async () => { + const mockUserEntity = generateOperator(); + mockUserRepository.findOneById.mockResolvedValueOnce(mockUserEntity); await expect( - userService.registerLabeler(userEntity as UserEntity), - ).rejects.toThrow( - new UserError( - UserErrorMessage.KYC_NOT_APPROVED, - userEntity.id as number, + userService.updatePassword( + faker.number.int(), + faker.internet.password(), ), - ); + ).rejects.toThrow('Only web2 users can have password'); + + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); }); - it('should return site key if user is already registered as a labeler', async () => { - const siteKeyEntity: DeepPartial = { - id: 1, - siteKey: 'site_key', - type: SiteKeyType.HCAPTCHA, - }; - const userEntity: DeepPartial = { - id: 1, - email: MOCK_EMAIL, - evmAddress: MOCK_ADDRESS, - role: Role.WORKER, - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, - siteKeys: [siteKeyEntity], - }; + it('should update password for requested user', async () => { + const mockUserEntity = generateWorkerUser(); + mockUserRepository.findOneById.mockResolvedValueOnce(mockUserEntity); - hcaptchaService.registerLabeler = jest.fn(); + const newPassword = faker.internet.password(); - const result = await userService.registerLabeler( - userEntity as UserEntity, + const result = await userService.updatePassword( + mockUserEntity.id, + newPassword, ); - expect(result).toEqual('site_key'); - expect(hcaptchaService.registerLabeler).toHaveBeenCalledTimes(0); - }); - - it('should throw LabelingEnableFailed if registering labeler fails', async () => { - const userEntity: DeepPartial = { - id: 1, - email: MOCK_EMAIL, - evmAddress: MOCK_ADDRESS, - role: Role.WORKER, - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, - }; - - hcaptchaService.registerLabeler = jest.fn().mockResolvedValueOnce(false); + expect( + securityUtils.comparePasswordWithHash(newPassword, result.password), + ).toBe(true); - await expect( - userService.registerLabeler(userEntity as UserEntity), - ).rejects.toThrow( - new UserError( - UserErrorMessage.LABELING_ENABLE_FAILED, - userEntity.id as number, - ), + expect(mockUserRepository.findOneById).toHaveBeenCalledTimes(1); + expect(mockUserRepository.findOneById).toHaveBeenCalledWith( + mockUserEntity.id, ); - }); - it('should throw LabelingEnableFailed if retrieving labeler data fails', async () => { - const userEntity: DeepPartial = { - id: 1, - email: MOCK_EMAIL, - evmAddress: MOCK_ADDRESS, - role: Role.WORKER, - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, + const expectedUserData = { + ...mockUserEntity, + password: expect.not.stringMatching(mockUserEntity.password), }; - hcaptchaService.registerLabeler = jest.fn().mockResolvedValueOnce(true); - hcaptchaService.getLabelerData = jest.fn().mockResolvedValueOnce(null); - - await expect( - userService.registerLabeler(userEntity as UserEntity), - ).rejects.toThrow( - new UserError( - UserErrorMessage.LABELING_ENABLE_FAILED, - userEntity.id as number, - ), + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockUserRepository.updateOne).toHaveBeenCalledWith( + expectedUserData, ); + + expect(result).toEqual(expectedUserData); }); + }); - it('should throw NoWalletAddresRegistered if user does not have an evm address', async () => { - const userEntity: DeepPartial = { - id: 1, - email: MOCK_EMAIL, - role: Role.WORKER, - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, + describe('createOperatorUser', () => { + it('should create operator user and return the created entity', async () => { + const newOperatorAddress = generateEthWallet().address; + + const expectedUserData = { + evmAddress: newOperatorAddress.toLowerCase(), + nonce: expect.any(String), + role: Role.OPERATOR, + status: UserStatus.ACTIVE, }; - hcaptchaService.registerLabeler = jest.fn().mockResolvedValueOnce(false); + const result = await userService.createOperatorUser(newOperatorAddress); - await expect( - userService.registerLabeler(userEntity as UserEntity), - ).rejects.toThrow( - new UserError( - UserErrorMessage.MISSING_ADDRESS, - userEntity.id as number, - ), + expect(mockUserRepository.createUnique).toHaveBeenCalledWith( + expectedUserData, ); + expect(result).toEqual(expectedUserData); }); }); - describe('registerAddress', () => { + describe('registerLabeler', () => { beforeEach(() => { - jest.spyOn(userRepository, 'findOneByAddress').mockResolvedValue(null); + mockHCaptchaService.registerLabeler.mockResolvedValue(false); + mockHCaptchaService.getLabelerData.mockResolvedValue(null); }); - afterEach(() => { - jest.resetAllMocks(); + it('should throw if not worker user', async () => { + const user = generateWorkerUser(); + user.role = Role.ADMIN; + + await expect(userService.registerLabeler(user)).rejects.toThrow( + new UserError(UserErrorMessage.INVALID_ROLE, user.id), + ); + + expect(mockHCaptchaService.registerLabeler).toHaveBeenCalledTimes(0); }); - it('should update evm address and sign the address', async () => { - const userEntity: DeepPartial = { - id: 1, - email: '', - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, - }; + it('should throw if worker does not have evm address', async () => { + const user = generateWorkerUser(); - const signature = await signMessage( - prepareSignatureBody({ - from: MOCK_ADDRESS, - to: MOCK_ADDRESS, - contents: SignatureType.REGISTER_ADDRESS, - nonce: undefined, - }), - MOCK_PRIVATE_KEY, + await expect(userService.registerLabeler(user)).rejects.toThrow( + new UserError(UserErrorMessage.MISSING_ADDRESS, user.id), ); - // Mock web3Service methods - web3Service.getSigner = jest.fn().mockReturnValue({ - signMessage: jest.fn().mockResolvedValue(signature), - }); + expect(mockHCaptchaService.registerLabeler).toHaveBeenCalledTimes(0); + }); - await userService.registerAddress(userEntity as UserEntity, { - address: MOCK_ADDRESS, - signature, + it('should throw if kyc is not approved', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, }); + user.kyc = generateKycEntity(user.id, KycStatus.NONE); - expect(userRepository.updateOne).toHaveBeenCalledWith(userEntity); - }); + await expect(userService.registerLabeler(user)).rejects.toThrow( + new UserError(UserErrorMessage.KYC_NOT_APPROVED, user.id), + ); - it('should fail if user already have a wallet address', async () => { - const userEntity: Partial = { - id: 1, - email: '', - evmAddress: '0x123', - }; + expect(mockHCaptchaService.registerLabeler).toHaveBeenCalledTimes(0); + }); - const address = '0x456'; - const signature = 'valid-signature'; + it('should return existing sitekey if already registered', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + user.kyc = generateKycEntity(user.id, KycStatus.APPROVED); - await expect( - userService.registerAddress(userEntity as UserEntity, { - address, - signature, - }), - ).rejects.toThrow( - new UserError(UserErrorMessage.ADDRESS_EXISTS, userEntity.id as number), + const existingSitekey = generateSiteKeyEntity( + user.id, + SiteKeyType.HCAPTCHA, ); - }); + user.siteKeys = [existingSitekey]; - it("should fail if user's kyc is not approved", async () => { - const userEntity: DeepPartial = { - id: 1, - email: '', - kyc: { - country: 'FR', - status: KycStatus.REVIEW, - }, - }; + const result = await userService.registerLabeler(user); - const address = '0x123'; - const signature = 'valid-signature'; + expect(result).toBe(existingSitekey.siteKey); - await expect( - userService.registerAddress(userEntity as UserEntity, { - address, - signature, - }), - ).rejects.toThrow( - new UserError( - UserErrorMessage.KYC_NOT_APPROVED, - userEntity.id as number, - ), - ); + expect(mockHCaptchaService.registerLabeler).toHaveBeenCalledTimes(0); }); - it("should fail if user's address already exists", async () => { - const userEntity: DeepPartial = { - id: 1, - email: '', - evmAddress: '0x123', - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, - }; - - const address = '0x123'; - const signature = 'valid-signature'; + it('should throw LabelingEnableFailed if registering labeler fails', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + user.kyc = generateKycEntity(user.id, KycStatus.APPROVED); - jest - .spyOn(userRepository, 'findOneByAddress') - .mockResolvedValue(userEntity as any); + mockHCaptchaService.registerLabeler.mockResolvedValueOnce(false); - await expect( - userService.registerAddress(userEntity as UserEntity, { - address, - signature, - }), - ).rejects.toThrow( - new UserError(UserErrorMessage.ADDRESS_EXISTS, userEntity.id as number), + await expect(userService.registerLabeler(user)).rejects.toThrow( + new UserError(UserErrorMessage.LABELING_ENABLE_FAILED, user.id), ); }); - it('should fail if address already registered with another user', async () => { - const userEntity: DeepPartial = { - id: 1, - email: '', - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, - }; + it('should throw LabelingEnableFailed if retrieving labeler data fails', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + user.kyc = generateKycEntity(user.id, KycStatus.APPROVED); - const address = '0x123'; - const signature = 'valid-signature'; + mockHCaptchaService.registerLabeler.mockResolvedValueOnce(true); - jest.spyOn(userRepository, 'findOneByAddress').mockResolvedValue({ - id: 2, - email: '', - evmAddress: '0x123', - } as any); + mockHCaptchaService.getLabelerData.mockResolvedValueOnce(null); - await expect( - userService.registerAddress(userEntity as UserEntity, { - address, - signature, - }), - ).rejects.toThrow( - new DuplicatedWalletAddressError(userEntity.id as number, address), + await expect(userService.registerLabeler(user)).rejects.toThrow( + new UserError(UserErrorMessage.LABELING_ENABLE_FAILED, user.id), ); }); - it('should fail if the signature is invalid', async () => { - const userEntity: DeepPartial = { - id: 1, - email: '', - kyc: { - country: 'FR', - status: KycStatus.APPROVED, - }, + it('should register labeler if not already registered', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + user.kyc = generateKycEntity(user.id, KycStatus.APPROVED); + user.siteKeys = [ + generateSiteKeyEntity(user.id, SiteKeyType.REGISTRATION), + ]; + mockHCaptchaService.registerLabeler.mockResolvedValueOnce(true); + + const registeredSitekey = faker.string.uuid(); + const mockedLabelerData = { + sitekeys: [{ sitekey: registeredSitekey }], }; + mockHCaptchaService.getLabelerData.mockResolvedValueOnce( + mockedLabelerData, + ); - const address = '0x123'; - const signature = 'invalid-signature'; + const result = await userService.registerLabeler(user); - // Mock web3Service methods - web3Service.getSigner = jest.fn().mockReturnValue({ - signMessage: jest.fn().mockResolvedValue('signature'), + expect(result).toBe(registeredSitekey); + + expect(mockHCaptchaService.registerLabeler).toHaveBeenCalledTimes(1); + expect(mockHCaptchaService.registerLabeler).toHaveBeenCalledWith({ + email: user.email, + evmAddress: user.evmAddress, + country: user.kyc.country, }); - await expect( - userService.registerAddress(userEntity as UserEntity, { - address, - signature, - }), - ).rejects.toThrow( - new InvalidWeb3SignatureError(userEntity.id as number, address), + expect(mockHCaptchaService.getLabelerData).toHaveBeenCalledTimes(1); + expect(mockHCaptchaService.getLabelerData).toHaveBeenCalledWith( + user.email, ); + + expect(mockSiteKeyRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockSiteKeyRepository.createUnique).toHaveBeenCalledWith({ + userId: user.id, + siteKey: registeredSitekey, + type: SiteKeyType.HCAPTCHA, + }); }); }); - describe('enableOperator', () => { - const signatureBody = prepareSignatureBody({ - from: MOCK_ADDRESS, - to: MOCK_ADDRESS, - contents: SignatureType.ENABLE_OPERATOR, - }); + describe('registerAddress', () => { + let addressToRegister: string; + let privateKey: string; - const userEntity: DeepPartial = { - id: 1, - evmAddress: MOCK_ADDRESS, - }; + beforeEach(() => { + ({ address: addressToRegister, privateKey } = generateEthWallet()); - afterEach(() => { - jest.resetAllMocks(); + mockUserRepository.findOneByAddress.mockResolvedValue(null); }); - it('should enable an operator', async () => { - const kvstoreClientMock = { - set: jest.fn(), - }; + it('should throw if already registered', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); - (KVStoreClient.build as any).mockImplementationOnce( - () => kvstoreClientMock, + await expect( + userService.registerAddress( + user, + addressToRegister, + faker.string.alphanumeric(), + ), + ).rejects.toThrow( + new UserError(UserErrorMessage.ADDRESS_EXISTS, user.id), ); - KVStoreUtils.get = jest.fn().mockResolvedValue(OperatorStatus.INACTIVE); - const signature = await signMessage(signatureBody, MOCK_PRIVATE_KEY); - - const result = await userService.enableOperator( - userEntity as any, - signature, - ); + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); + }); - expect(result).toBe(undefined); - expect(web3Service.getSigner).toHaveBeenCalledWith(ChainId.POLYGON_AMOY); + it('should throw if kyc is not approved', async () => { + const user = generateWorkerUser(); + user.kyc = generateKycEntity(user.id, KycStatus.NONE); - expect(KVStoreUtils.get).toHaveBeenCalledWith( - ChainId.POLYGON_AMOY, - MOCK_ADDRESS, - MOCK_ADDRESS, - ); - expect(kvstoreClientMock.set).toHaveBeenCalledWith( - MOCK_ADDRESS.toLowerCase(), - OperatorStatus.ACTIVE, + await expect( + userService.registerAddress( + user, + addressToRegister, + faker.string.alphanumeric(), + ), + ).rejects.toThrow( + new UserError(UserErrorMessage.KYC_NOT_APPROVED, user.id), ); + + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); }); - it("should throw ConflictException if signature doesn't match", async () => { - const kvstoreClientMock = { - set: jest.fn(), - }; - (KVStoreClient.build as any).mockImplementationOnce( - () => kvstoreClientMock, - ); + it('should throw if same address already exists', async () => { + const user = generateWorkerUser(); + user.kyc = generateKycEntity(user.id, KycStatus.APPROVED); - KVStoreUtils.get = jest.fn().mockResolvedValue(OperatorStatus.INACTIVE); + mockUserRepository.findOneByAddress.mockImplementationOnce( + async (address: string) => { + if (address === addressToRegister.toLowerCase()) { + return user; + } - const invalidSignature = await signMessage( - 'invalid message', - MOCK_PRIVATE_KEY, + return null; + }, ); await expect( - userService.enableOperator(userEntity as any, invalidSignature), - ).rejects.toThrow( - new InvalidWeb3SignatureError( - userEntity.id as number, - userEntity.evmAddress as string, + userService.registerAddress( + user, + addressToRegister, + faker.string.alphanumeric(), ), + ).rejects.toThrow( + new DuplicatedWalletAddressError(user.id, addressToRegister), ); - }); - it('should throw BadRequestException if operator already enabled in KVStore', async () => { - KVStoreUtils.get = jest.fn().mockResolvedValue(OperatorStatus.ACTIVE); + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); + }); - const signature = await signMessage(signatureBody, MOCK_PRIVATE_KEY); + it('should throw if invalid signature', async () => { + const user = generateWorkerUser(); + user.kyc = generateKycEntity(user.id, KycStatus.APPROVED); await expect( - userService.enableOperator(userEntity as any, signature), - ).rejects.toThrow( - new UserError( - UserErrorMessage.OPERATOR_ALREADY_ACTIVE, - userEntity.id as number, + userService.registerAddress( + user, + addressToRegister, + faker.string.alphanumeric(), ), + ).rejects.toThrow( + new InvalidWeb3SignatureError(user.id, addressToRegister), ); - }); - }); - describe('disableOperator', () => { - const signatureBody = prepareSignatureBody({ - from: MOCK_ADDRESS, - to: MOCK_ADDRESS, - contents: SignatureType.DISABLE_OPERATOR, + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); }); - const userEntity: DeepPartial = { - id: 1, - evmAddress: MOCK_ADDRESS, - }; + it('should register evm address for user', async () => { + const user = generateWorkerUser(); + user.kyc = generateKycEntity(user.id, KycStatus.APPROVED); - afterEach(() => { - jest.resetAllMocks(); - }); + const signature = await web3Utils.signMessage( + web3Utils.prepareSignatureBody({ + from: addressToRegister, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.REGISTER_ADDRESS, + }), + privateKey, + ); - it('should disable a user', async () => { - const kvstoreClientMock = { - set: jest.fn(), - }; + await userService.registerAddress(user, addressToRegister, signature); - (KVStoreClient.build as any).mockImplementationOnce( - () => kvstoreClientMock, - ); + expect(user.evmAddress).toBe(addressToRegister.toLowerCase()); - KVStoreUtils.get = jest.fn().mockResolvedValue(OperatorStatus.ACTIVE); - const signature = await signMessage(signatureBody, MOCK_PRIVATE_KEY); + expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(1); + expect(mockUserRepository.updateOne).toHaveBeenCalledWith(user); + }); + }); - const result = await userService.disableOperator( - userEntity as any, - signature, - ); + describe('getRegistrationInExchangeOracles', () => { + it('should return a list of registered sitekeys', async () => { + const user = generateWorkerUser(); + const siteKey = generateSiteKeyEntity(user.id, SiteKeyType.REGISTRATION); - expect(result).toBe(undefined); - expect(web3Service.getSigner).toHaveBeenCalledWith(ChainId.POLYGON_AMOY); + mockSiteKeyRepository.findByUserAndType.mockResolvedValueOnce([siteKey]); - expect(KVStoreUtils.get).toHaveBeenCalledWith( - ChainId.POLYGON_AMOY, - MOCK_ADDRESS, - MOCK_ADDRESS, - ); - expect(kvstoreClientMock.set).toHaveBeenCalledWith( - MOCK_ADDRESS.toLowerCase(), - OperatorStatus.INACTIVE, - ); + const result = await userService.getRegistrationInExchangeOracles(user); + + expect(result).toEqual([siteKey.siteKey]); }); + }); - it("should throw ConflictException if signature doesn't match", async () => { - const kvstoreClientMock = { - set: jest.fn(), - }; - (KVStoreClient.build as any).mockImplementationOnce( - () => kvstoreClientMock, + describe('registrationInExchangeOracle', () => { + it('should not create sitekey if already registered', async () => { + const user = generateWorkerUser(); + const siteKey = generateSiteKeyEntity(user.id, SiteKeyType.REGISTRATION); + const oracleAddress = siteKey.siteKey; + + mockSiteKeyRepository.findByUserSiteKeyAndType.mockImplementationOnce( + async (userId, sitekey, type) => { + if ( + userId === user.id && + sitekey === oracleAddress && + type === SiteKeyType.REGISTRATION + ) { + return siteKey; + } + return null; + }, ); - KVStoreUtils.get = jest.fn().mockResolvedValue(OperatorStatus.ACTIVE); + await userService.registrationInExchangeOracle(user, oracleAddress); - const invalidSignature = await signMessage( - 'invalid message', - MOCK_PRIVATE_KEY, - ); - - await expect( - userService.disableOperator(userEntity as any, invalidSignature), - ).rejects.toThrow( - new InvalidWeb3SignatureError( - userEntity.id as number, - userEntity.evmAddress as string, - ), - ); + expect(mockSiteKeyRepository.createUnique).toHaveBeenCalledTimes(0); }); - it('should throw UserErrorMessage.OPERATOR_NOT_ACTIVE if operator already disabled in KVStore', async () => { - KVStoreUtils.get = jest.fn().mockResolvedValue(OperatorStatus.INACTIVE); - const signature = await signMessage(signatureBody, MOCK_PRIVATE_KEY); + it('should create a new registration for oracle', async () => { + const user = generateWorkerUser(); + const oracleAddress = generateEthWallet().address; - await expect( - userService.disableOperator(userEntity as any, signature), - ).rejects.toThrow( - new UserError( - UserErrorMessage.OPERATOR_NOT_ACTIVE, - userEntity.id as number, - ), + mockSiteKeyRepository.findByUserSiteKeyAndType.mockResolvedValueOnce( + null, ); + + await userService.registrationInExchangeOracle(user, oracleAddress); + + expect(mockSiteKeyRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockSiteKeyRepository.createUnique).toHaveBeenCalledWith({ + userId: user.id, + siteKey: oracleAddress, + type: SiteKeyType.REGISTRATION, + }); }); }); - describe('prepareSignatureBody', () => { - afterEach(() => { - jest.clearAllMocks(); + describe('enableOperator', () => { + const mockedKVStoreSet = jest.fn(); + + beforeEach(() => { + mockedKVStoreClient.build.mockResolvedValueOnce({ + set: mockedKVStoreSet, + } as unknown as KVStoreClient); }); - it('should prepare web3 pre sign up payload and return typed structured data', async () => { - const expectedData: SignatureBodyDto = { - from: MOCK_ADDRESS.toLowerCase(), - to: MOCK_ADDRESS.toLowerCase(), - contents: 'signup', - nonce: undefined, - }; + it('should throw if signature is not verified', async () => { + const privateKey = generateEthWallet().privateKey; + const user = generateOperator(); - const result = prepareSignatureBody({ - from: MOCK_ADDRESS, - to: MOCK_ADDRESS, - contents: SignatureType.SIGNUP, + const signatureBody = web3Utils.prepareSignatureBody({ + from: user.evmAddress, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.ENABLE_OPERATOR, }); + const signature = await web3Utils.signMessage(signatureBody, privateKey); - expect(result).toStrictEqual(expectedData); + await expect(userService.enableOperator(user, signature)).rejects.toThrow( + new InvalidWeb3SignatureError(user.id, user.evmAddress), + ); + expect(mockedKVStoreSet).toHaveBeenCalledTimes(0); }); - it('should prepare web3 pre register address payload and return typed structured data', async () => { - const expectedData: SignatureBodyDto = { - from: MOCK_ADDRESS.toLowerCase(), - to: MOCK_ADDRESS.toLowerCase(), - contents: 'register_address', - nonce: undefined, - }; + it('should throw if operator already enabled', async () => { + const privateKey = generateEthWallet().privateKey; + const user = generateOperator({ privateKey }); - const result = prepareSignatureBody({ - from: MOCK_ADDRESS, - to: MOCK_ADDRESS, - contents: SignatureType.REGISTER_ADDRESS, + const signatureBody = web3Utils.prepareSignatureBody({ + from: user.evmAddress, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.ENABLE_OPERATOR, }); + const signature = await web3Utils.signMessage(signatureBody, privateKey); + + mockedKVStoreUtils.get.mockResolvedValueOnce(OperatorStatus.ACTIVE); - expect(result).toStrictEqual(expectedData); + await expect(userService.enableOperator(user, signature)).rejects.toThrow( + new UserError(UserErrorMessage.OPERATOR_ALREADY_ACTIVE, user.id), + ); + expect(mockedKVStoreUtils.get).toHaveBeenCalledTimes(1); + expect(mockedKVStoreUtils.get).toHaveBeenCalledWith( + mockWeb3ConfigService.reputationNetworkChainId, + mockWeb3ConfigService.operatorAddress, + user.evmAddress, + ); + expect(mockedKVStoreSet).toHaveBeenCalledTimes(0); }); - }); - describe('registrationInExchangeOracle', () => { - it('should register a new registration in a Exchange Oracle for the user', async () => { - const userEntity: DeepPartial = { - id: 1, - email: 'test@example.com', - }; + it('should enable operator', async () => { + const privateKey = generateEthWallet().privateKey; + const user = generateOperator({ privateKey }); - const oracleRegistration: RegistrationInExchangeOracleDto = { - oracleAddress: '0xOracleAddress', - hCaptchaToken: 'hcaptcha-token', - }; + const signatureBody = web3Utils.prepareSignatureBody({ + from: user.evmAddress, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.ENABLE_OPERATOR, + }); + const signature = await web3Utils.signMessage(signatureBody, privateKey); - const siteKeyMock: DeepPartial = { - siteKey: oracleRegistration.oracleAddress, - type: SiteKeyType.REGISTRATION, - user: userEntity, - }; - jest.spyOn(hcaptchaService, 'verifyToken').mockResolvedValueOnce(true); - jest - .spyOn(siteKeyRepository, 'findByUserSiteKeyAndType') - .mockResolvedValueOnce(null); - jest - .spyOn(siteKeyRepository, 'createUnique') - .mockResolvedValueOnce(siteKeyMock as SiteKeyEntity); - - const result = await userService.registrationInExchangeOracle( - userEntity as UserEntity, - oracleRegistration.oracleAddress, - ); + await userService.enableOperator(user, signature); - expect(siteKeyRepository.createUnique).toHaveBeenCalledWith( - expect.objectContaining({ - siteKey: oracleRegistration.oracleAddress, - type: SiteKeyType.REGISTRATION, - user: userEntity, - }), + expect(mockedKVStoreSet).toHaveBeenCalledTimes(1); + expect(mockedKVStoreSet).toHaveBeenCalledWith( + user.evmAddress, + OperatorStatus.ACTIVE, ); + }); + }); + + describe('disableOperator', () => { + const mockedKVStoreSet = jest.fn(); - expect(result).toEqual(siteKeyMock); + beforeEach(() => { + mockedKVStoreClient.build.mockResolvedValueOnce({ + set: mockedKVStoreSet, + } as unknown as KVStoreClient); }); - it('should not register a new oracle for the user and return the existing one', async () => { - const userEntity: DeepPartial = { - id: 1, - email: 'test@example.com', - }; + it('should throw if signature is not verified', async () => { + const privateKey = generateEthWallet().privateKey; + const user = generateOperator(); - const oracleRegistration: RegistrationInExchangeOracleDto = { - oracleAddress: '0xOracleAddress', - hCaptchaToken: 'hcaptcha-token', - }; + const signatureBody = web3Utils.prepareSignatureBody({ + from: user.evmAddress, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.DISABLE_OPERATOR, + }); + const signature = await web3Utils.signMessage(signatureBody, privateKey); - const siteKeyMock: DeepPartial = { - siteKey: oracleRegistration.oracleAddress, - type: SiteKeyType.REGISTRATION, - user: userEntity, - }; - jest.spyOn(hcaptchaService, 'verifyToken').mockResolvedValueOnce(true); - jest - .spyOn(siteKeyRepository, 'findByUserSiteKeyAndType') - .mockResolvedValueOnce(siteKeyMock as SiteKeyEntity); - - const result = await userService.registrationInExchangeOracle( - userEntity as UserEntity, - oracleRegistration.oracleAddress, + await expect( + userService.disableOperator(user, signature), + ).rejects.toThrow( + new InvalidWeb3SignatureError(user.id, user.evmAddress), ); + expect(mockedKVStoreSet).toHaveBeenCalledTimes(0); + }); + + it('should throw if operator already enabled', async () => { + const privateKey = generateEthWallet().privateKey; + const user = generateOperator({ privateKey }); + + const signatureBody = web3Utils.prepareSignatureBody({ + from: user.evmAddress, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.DISABLE_OPERATOR, + }); + const signature = await web3Utils.signMessage(signatureBody, privateKey); - expect(siteKeyRepository.createUnique).not.toHaveBeenCalled(); + mockedKVStoreUtils.get.mockResolvedValueOnce(OperatorStatus.INACTIVE); - expect(result).toEqual(siteKeyMock); + await expect( + userService.disableOperator(user, signature), + ).rejects.toThrow( + new UserError(UserErrorMessage.OPERATOR_NOT_ACTIVE, user.id), + ); + expect(mockedKVStoreUtils.get).toHaveBeenCalledTimes(1); + expect(mockedKVStoreUtils.get).toHaveBeenCalledWith( + mockWeb3ConfigService.reputationNetworkChainId, + mockWeb3ConfigService.operatorAddress, + user.evmAddress, + ); + expect(mockedKVStoreSet).toHaveBeenCalledTimes(0); }); - }); - describe('getRegisteredOracles', () => { - it('should return a list of registered oracles for the user', async () => { - const userEntity: DeepPartial = { - id: 1, - email: 'test@example.com', - }; + it('should disable operator', async () => { + const privateKey = generateEthWallet().privateKey; + const user = generateOperator({ privateKey }); - const siteKeys: SiteKeyEntity[] = [ - { siteKey: '0xOracleAddress1' } as SiteKeyEntity, - { siteKey: '0xOracleAddress2' } as SiteKeyEntity, - ]; + const signatureBody = web3Utils.prepareSignatureBody({ + from: user.evmAddress, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.DISABLE_OPERATOR, + }); + const signature = await web3Utils.signMessage(signatureBody, privateKey); - jest - .spyOn(siteKeyRepository, 'findByUserAndType') - .mockResolvedValue(siteKeys); + await userService.disableOperator(user, signature); - const result = await userService.getRegistrationInExchangeOracles( - userEntity as UserEntity, + expect(mockedKVStoreSet).toHaveBeenCalledTimes(1); + expect(mockedKVStoreSet).toHaveBeenCalledWith( + user.evmAddress, + OperatorStatus.INACTIVE, ); - - expect(result).toEqual(['0xOracleAddress1', '0xOracleAddress2']); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts index 5ab096df7a..24cc83b788 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts @@ -1,23 +1,19 @@ import { KVStoreClient, KVStoreUtils } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; -import * as bcrypt from 'bcrypt'; -import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; -import { Web3ConfigService } from '../../config/web3-config.service'; -import { OperatorStatus } from '../../common/enums/user'; -import { KycStatus } from '../kyc/constants'; + import { SignatureType } from '../../common/enums/web3'; -import { SiteKeyType } from '../../common/enums'; +import { Web3ConfigService } from '../../config/web3-config.service'; import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; -import { - generateNonce, - verifySignature, - prepareSignatureBody, -} from '../../utils/web3'; +import * as securityUtils from '../../utils/security'; +import * as web3Utils from '../../utils/web3'; + +import { KycStatus } from '../kyc/constants'; import { Web3Service } from '../web3/web3.service'; -import { SiteKeyEntity } from './site-key.entity'; + +import { SiteKeyEntity, SiteKeyType } from './site-key.entity'; import { SiteKeyRepository } from './site-key.repository'; -import { RegisterAddressRequestDto } from './user.dto'; -import { Role, UserStatus, UserEntity } from './user.entity'; +import { OperatorUserEntity, Web2UserEntity } from './types'; +import { Role as UserRole, UserStatus, UserEntity } from './user.entity'; import { UserError, UserErrorMessage, @@ -26,70 +22,110 @@ import { } from './user.error'; import { UserRepository } from './user.repository'; +export enum OperatorStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', +} + @Injectable() export class UserService { - private readonly HASH_ROUNDS = 12; - constructor( - private userRepository: UserRepository, - private siteKeyRepository: SiteKeyRepository, + private readonly userRepository: UserRepository, + private readonly siteKeyRepository: SiteKeyRepository, private readonly web3Service: Web3Service, private readonly hcaptchaService: HCaptchaService, private readonly web3ConfigService: Web3ConfigService, - private readonly hcaptchaConfigService: HCaptchaConfigService, ) {} - static checkPasswordMatchesHash( - password: string, - passwordHash: string, - ): boolean { - return bcrypt.compareSync(password, passwordHash); + static isWeb2UserRole(userRole: string): boolean { + return [UserRole.ADMIN, UserRole.HUMAN_APP, UserRole.WORKER].includes( + userRole as UserRole, + ); } - public async create({ - email, - password, - }: Pick): Promise { + async createWorkerUser(data: { + email: string; + password: string; + }): Promise { const newUser = new UserEntity(); - newUser.email = email; - newUser.password = bcrypt.hashSync(password, this.HASH_ROUNDS); - newUser.role = Role.WORKER; + newUser.email = data.email; + newUser.password = securityUtils.hashPassword(data.password); + newUser.role = UserRole.WORKER; newUser.status = UserStatus.PENDING; + await this.userRepository.createUnique(newUser); - return newUser; + + return newUser as Web2UserEntity; } - public updatePassword( - userEntity: UserEntity, - newPassword: string, - ): Promise { - userEntity.password = bcrypt.hashSync(newPassword, this.HASH_ROUNDS); - return this.userRepository.updateOne(userEntity); + async findWeb2UserByEmail(email: string): Promise { + const userEntity = await this.userRepository.findOneByEmail(email, { + relations: { + kyc: true, + siteKeys: true, + userQualifications: { + qualification: true, + }, + }, + }); + + if (userEntity && UserService.isWeb2UserRole(userEntity.role)) { + return userEntity as Web2UserEntity; + } + + return null; } - public activate(userEntity: UserEntity): Promise { - userEntity.status = UserStatus.ACTIVE; - return this.userRepository.updateOne(userEntity); + async updatePassword( + userId: number, + newPassword: string, + ): Promise { + const userEntity = await this.userRepository.findOneById(userId); + + if (!userEntity) { + throw new Error('User not found'); + } + + if (!UserService.isWeb2UserRole(userEntity.role)) { + throw new Error('Only web2 users can have password'); + } + + userEntity.password = securityUtils.hashPassword(newPassword); + + await this.userRepository.updateOne(userEntity); + + return userEntity as Web2UserEntity; } - public async createWeb3User(address: string): Promise { + async createOperatorUser(address: string): Promise { const newUser = new UserEntity(); newUser.evmAddress = address.toLowerCase(); - newUser.nonce = generateNonce(); - newUser.role = Role.OPERATOR; + newUser.nonce = web3Utils.generateNonce(); + newUser.role = UserRole.OPERATOR; newUser.status = UserStatus.ACTIVE; await this.userRepository.createUnique(newUser); - return newUser; + + return newUser as OperatorUserEntity; } - public async updateNonce(userEntity: UserEntity): Promise { - userEntity.nonce = generateNonce(); + async findOperatorUser(address: string): Promise { + const userEntity = await this.userRepository.findOneByAddress(address); + + if (userEntity && userEntity.role === UserRole.OPERATOR) { + return userEntity as OperatorUserEntity; + } + + return null; + } + + async updateNonce(userEntity: OperatorUserEntity): Promise { + userEntity.nonce = web3Utils.generateNonce(); return this.userRepository.updateOne(userEntity); } - public async registerLabeler(user: UserEntity): Promise { - if (user.role !== Role.WORKER) { + async registerLabeler(user: Web2UserEntity): Promise { + if (user.role !== UserRole.WORKER) { throw new UserError(UserErrorMessage.INVALID_ROLE, user.id); } @@ -101,6 +137,7 @@ export class UserService { throw new UserError(UserErrorMessage.KYC_NOT_APPROVED, user.id); } + // TODO: load sitekeys from repository instead of user entity in request if (user.siteKeys && user.siteKeys.length > 0) { const existingHcaptchaSiteKey = user.siteKeys?.find( (key) => key.type === SiteKeyType.HCAPTCHA, @@ -131,7 +168,7 @@ export class UserService { const newSiteKey = new SiteKeyEntity(); newSiteKey.siteKey = siteKey; - newSiteKey.user = user; + newSiteKey.userId = user.id; newSiteKey.type = SiteKeyType.HCAPTCHA; await this.siteKeyRepository.createUnique(newSiteKey); @@ -139,11 +176,12 @@ export class UserService { return siteKey; } - public async registerAddress( + async registerAddress( user: UserEntity, - data: RegisterAddressRequestDto, + address: string, + signature: string, ): Promise { - const lowercasedAddress = data.address.toLocaleLowerCase(); + const lowercasedAddress = address.toLocaleLowerCase(); if (user.evmAddress) { throw new UserError(UserErrorMessage.ADDRESS_EXISTS, user.id); @@ -153,41 +191,44 @@ export class UserService { throw new UserError(UserErrorMessage.KYC_NOT_APPROVED, user.id); } - const dbUser = + const userWithSameAddress = await this.userRepository.findOneByAddress(lowercasedAddress); - if (dbUser) { - throw new DuplicatedWalletAddressError(user.id, lowercasedAddress); + + if (userWithSameAddress) { + throw new DuplicatedWalletAddressError(user.id, address); } // Prepare signed data and verify the signature - const signedData = prepareSignatureBody({ - from: lowercasedAddress, + const signedData = web3Utils.prepareSignatureBody({ + from: address, to: this.web3ConfigService.operatorAddress, contents: SignatureType.REGISTER_ADDRESS, }); - const verified = verifySignature(signedData, data.signature, [ + const verified = web3Utils.verifySignature(signedData, signature, [ lowercasedAddress, ]); if (!verified) { - throw new InvalidWeb3SignatureError(user.id, lowercasedAddress); + throw new InvalidWeb3SignatureError(user.id, address); } user.evmAddress = lowercasedAddress; await this.userRepository.updateOne(user); } - public async enableOperator( - user: UserEntity, + async enableOperator( + user: OperatorUserEntity, signature: string, ): Promise { - const signedData = prepareSignatureBody({ + const signedData = web3Utils.prepareSignatureBody({ from: user.evmAddress, to: this.web3ConfigService.operatorAddress, contents: SignatureType.ENABLE_OPERATOR, }); - const verified = verifySignature(signedData, signature, [user.evmAddress]); + const verified = web3Utils.verifySignature(signedData, signature, [ + user.evmAddress, + ]); if (!verified) { throw new InvalidWeb3SignatureError(user.id, user.evmAddress); @@ -206,20 +247,22 @@ export class UserService { throw new UserError(UserErrorMessage.OPERATOR_ALREADY_ACTIVE, user.id); } - await kvstore.set(user.evmAddress.toLowerCase(), OperatorStatus.ACTIVE); + await kvstore.set(user.evmAddress, OperatorStatus.ACTIVE); } - public async disableOperator( - user: UserEntity, + async disableOperator( + user: OperatorUserEntity, signature: string, ): Promise { - const signedData = prepareSignatureBody({ + const signedData = web3Utils.prepareSignatureBody({ from: user.evmAddress, to: this.web3ConfigService.operatorAddress, contents: SignatureType.DISABLE_OPERATOR, }); - const verified = verifySignature(signedData, signature, [user.evmAddress]); + const verified = web3Utils.verifySignature(signedData, signature, [ + user.evmAddress, + ]); if (!verified) { throw new InvalidWeb3SignatureError(user.id, user.evmAddress); @@ -239,33 +282,33 @@ export class UserService { throw new UserError(UserErrorMessage.OPERATOR_NOT_ACTIVE, user.id); } - await kvstore.set(user.evmAddress.toLowerCase(), OperatorStatus.INACTIVE); + await kvstore.set(user.evmAddress, OperatorStatus.INACTIVE); } - public async registrationInExchangeOracle( - user: UserEntity, + async registrationInExchangeOracle( + user: Web2UserEntity, oracleAddress: string, - ): Promise { + ): Promise { const siteKey = await this.siteKeyRepository.findByUserSiteKeyAndType( - user, + user.id, oracleAddress, SiteKeyType.REGISTRATION, ); - if (siteKey) return siteKey; + if (siteKey) { + return; + } const newSiteKey = new SiteKeyEntity(); newSiteKey.siteKey = oracleAddress; newSiteKey.type = SiteKeyType.REGISTRATION; - newSiteKey.user = user; + newSiteKey.userId = user.id; - return await this.siteKeyRepository.createUnique(newSiteKey); + await this.siteKeyRepository.createUnique(newSiteKey); } - public async getRegistrationInExchangeOracles( - user: UserEntity, - ): Promise { + async getRegistrationInExchangeOracles(user: UserEntity): Promise { const siteKeys = await this.siteKeyRepository.findByUserAndType( - user, + user.id, SiteKeyType.REGISTRATION, ); return siteKeys.map((siteKey) => siteKey.siteKey); diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts b/packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts new file mode 100644 index 0000000000..c26c03ab62 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/web3/fixtures.ts @@ -0,0 +1,20 @@ +import { faker } from '@faker-js/faker'; +import { + Web3ConfigService, + Web3Network, +} from '../../config/web3-config.service'; +import { + generateEthWallet, + generateTestnetChainId, +} from '../../../test/fixtures/web3'; + +const testWallet = generateEthWallet(); + +export const mockWeb3ConfigService: Omit = { + privateKey: testWallet.privateKey, + operatorAddress: testWallet.address, + network: Web3Network.TESTNET, + gasPriceMultiplier: faker.number.int({ min: 1, max: 42 }), + reputationNetworkChainId: generateTestnetChainId(), + getRpcUrlByChainId: () => faker.internet.url(), +}; diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts index f2f30c31ed..ed3d325e59 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.spec.ts @@ -1,28 +1,13 @@ +import { createMock } from '@golevelup/ts-jest'; import { Test } from '@nestjs/testing'; -import { FeeData, JsonRpcProvider } from 'ethers'; +import { FeeData, JsonRpcProvider, Provider } from 'ethers'; import { faker } from '@faker-js/faker'; import { WalletWithProvider, Web3Service } from './web3.service'; -import { - Web3ConfigService, - Web3Network, -} from '../../config/web3-config.service'; -import { - generateEthWallet, - generateTestnetChainId, -} from '../../../test/fixtures/web3'; -import { createMockProvider } from '../../../test/mock-creators/web3'; - -const testWallet = generateEthWallet(); - -const mockRpcUrl = faker.internet.url(); - -const mockWeb3ConfigService = { - privateKey: testWallet.privateKey, - operatorAddress: testWallet.address, - network: Web3Network.TESTNET, - gasPriceMultiplier: faker.number.int({ min: 1, max: 42 }), - getRpcUrlByChainId: () => mockRpcUrl, -}; +import { Web3ConfigService } from '../../config/web3-config.service'; + +import { generateTestnetChainId } from '../../../test/fixtures/web3'; + +import { mockWeb3ConfigService } from './fixtures'; describe('Web3Service', () => { let web3Service: Web3Service; @@ -55,8 +40,8 @@ describe('Web3Service', () => { const signer = web3Service.getSigner(validChainId); expect(signer).toBeDefined(); - expect(signer.address).toEqual(testWallet.address); - expect(signer.privateKey).toEqual(testWallet.privateKey); + expect(signer.address).toEqual(mockWeb3ConfigService.operatorAddress); + expect(signer.privateKey).toEqual(mockWeb3ConfigService.privateKey); expect(signer.provider).toBeInstanceOf(JsonRpcProvider); }); @@ -70,7 +55,7 @@ describe('Web3Service', () => { }); describe('calculateGasPrice', () => { - const mockProvider = createMockProvider(); + const mockProvider = createMock(); let spyOnGetSigner: jest.SpyInstance; beforeAll(() => { diff --git a/packages/apps/reputation-oracle/server/src/utils/security.ts b/packages/apps/reputation-oracle/server/src/utils/security.ts new file mode 100644 index 0000000000..3503adb801 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/utils/security.ts @@ -0,0 +1,14 @@ +import * as bcrypt from 'bcrypt'; + +export function hashPassword(password: string): string { + const SALT_GENERATION_ROUNDS = 12; + + return bcrypt.hashSync(password, SALT_GENERATION_ROUNDS); +} + +export function comparePasswordWithHash( + password: string, + passwordHash: string, +): boolean { + return bcrypt.compareSync(password, passwordHash); +} diff --git a/packages/apps/reputation-oracle/server/src/utils/web3.spec.ts b/packages/apps/reputation-oracle/server/src/utils/web3.spec.ts index 97919e38e1..666e1c1ba5 100644 --- a/packages/apps/reputation-oracle/server/src/utils/web3.spec.ts +++ b/packages/apps/reputation-oracle/server/src/utils/web3.spec.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { generateEthWallet } from '../../test/fixtures/web3'; -import { verifySignature, recoverSignerAddress, signMessage } from './web3'; +import * as web3Utils from './web3'; const PERMANENT_PRIVATE_KEY = '0x85dc77260240f78982bdfdfc0a0cb241a85d2f9833468fae7ec362ec7829ce3a'; @@ -25,7 +25,7 @@ describe('Web3 utilities', () => { it('should sign message when it is a string', async () => { const message = faker.lorem.words(); - const signature = await signMessage(message, privateKey); + const signature = await web3Utils.signMessage(message, privateKey); expect(signature).toMatch(signatureRegex); }); @@ -35,13 +35,13 @@ describe('Web3 utilities', () => { [faker.string.sample()]: new Date(), }; - const signature = await signMessage(message, privateKey); + const signature = await web3Utils.signMessage(message, privateKey); expect(signature).toMatch(signatureRegex); }); it('should return exact signature', async () => { - const signature = await signMessage( + const signature = await web3Utils.signMessage( PERMANENT_MESSAGE, PERMANENT_PRIVATE_KEY, ); @@ -52,9 +52,11 @@ describe('Web3 utilities', () => { describe('verifySignature', () => { it('should return true for valid exact signature', async () => { - const result = verifySignature(PERMANENT_MESSAGE, PERMANENT_SIGNATURE, [ - PERMANENT_ADDRESS, - ]); + const result = web3Utils.verifySignature( + PERMANENT_MESSAGE, + PERMANENT_SIGNATURE, + [PERMANENT_ADDRESS], + ); expect(result).toBe(true); }); @@ -62,9 +64,9 @@ describe('Web3 utilities', () => { it('should return true for valid signature', async () => { const message = faker.lorem.words(); - const signature = await signMessage(message, privateKey); + const signature = await web3Utils.signMessage(message, privateKey); - const result = verifySignature(message, signature, [address]); + const result = web3Utils.verifySignature(message, signature, [address]); expect(result).toBe(true); }); @@ -73,7 +75,9 @@ describe('Web3 utilities', () => { const message = faker.lorem.words(); const invalidSignature = '0xInvalidSignature'; - const result = verifySignature(message, invalidSignature, [address]); + const result = web3Utils.verifySignature(message, invalidSignature, [ + address, + ]); expect(result).toBe(false); }); @@ -81,10 +85,10 @@ describe('Web3 utilities', () => { it('should return false when signature not verified', async () => { const message = faker.lorem.words(); - const signature = await signMessage(message, privateKey); + const signature = await web3Utils.signMessage(message, privateKey); const { address: anotherSignerAddress } = generateEthWallet(); - const result = verifySignature(message, signature, [ + const result = web3Utils.verifySignature(message, signature, [ anotherSignerAddress, ]); @@ -94,7 +98,7 @@ describe('Web3 utilities', () => { describe('recoverSignerAddress', () => { it('should recover the exact signer', async () => { - const result = recoverSignerAddress( + const result = web3Utils.recoverSignerAddress( PERMANENT_MESSAGE, PERMANENT_SIGNATURE, ); @@ -104,9 +108,9 @@ describe('Web3 utilities', () => { it('should recover the correct signer', async () => { const message = faker.lorem.words(); - const signature = await signMessage(message, privateKey); + const signature = await web3Utils.signMessage(message, privateKey); - const result = recoverSignerAddress(message, signature); + const result = web3Utils.recoverSignerAddress(message, signature); expect(result).toBe(address); }); @@ -115,7 +119,7 @@ describe('Web3 utilities', () => { const message = faker.lorem.words(); const invalidSignature = '0xInvalidSignature'; - const signer = recoverSignerAddress(message, invalidSignature); + const signer = web3Utils.recoverSignerAddress(message, invalidSignature); expect(signer).toBe(null); }); @@ -124,11 +128,55 @@ describe('Web3 utilities', () => { const message = { [faker.string.sample()]: new Date(), }; - const signature = await signMessage(message, privateKey); + const signature = await web3Utils.signMessage(message, privateKey); - const recoveredAddress = recoverSignerAddress(message, signature); + const recoveredAddress = web3Utils.recoverSignerAddress( + message, + signature, + ); expect(recoveredAddress).toBe(address); }); }); + + describe('prepareSignatureBody', () => { + it('should prepare proper signature body', () => { + const from = generateEthWallet().address; + const to = generateEthWallet().address; + const contents = faker.string.alphanumeric(); + const nonce = faker.string.alphanumeric(); + + const preparedSignatureBody = web3Utils.prepareSignatureBody({ + from, + to, + contents, + nonce, + }); + + expect(preparedSignatureBody).toEqual({ + from: from.toLowerCase(), + to: to.toLowerCase(), + contents, + nonce, + }); + }); + + it('should not have nonce if not provided', () => { + const from = generateEthWallet().address; + const to = generateEthWallet().address; + const contents = faker.string.alphanumeric(); + + const preparedSignatureBody = web3Utils.prepareSignatureBody({ + from, + to, + contents, + }); + + expect(preparedSignatureBody).toEqual({ + from: from.toLowerCase(), + to: to.toLowerCase(), + contents, + }); + }); + }); }); diff --git a/packages/apps/reputation-oracle/server/src/utils/web3.ts b/packages/apps/reputation-oracle/server/src/utils/web3.ts index e6eabf0416..ed74911d73 100644 --- a/packages/apps/reputation-oracle/server/src/utils/web3.ts +++ b/packages/apps/reputation-oracle/server/src/utils/web3.ts @@ -70,6 +70,6 @@ export function prepareSignatureBody({ from: from.toLowerCase(), to: to.toLowerCase(), contents, - nonce: nonce ?? undefined, + nonce, }; } diff --git a/packages/apps/reputation-oracle/server/test/fixtures/web3.ts b/packages/apps/reputation-oracle/server/test/fixtures/web3.ts index ec816841c3..16075d6505 100644 --- a/packages/apps/reputation-oracle/server/test/fixtures/web3.ts +++ b/packages/apps/reputation-oracle/server/test/fixtures/web3.ts @@ -1,13 +1,15 @@ import { faker } from '@faker-js/faker'; import { ChainId } from '@human-protocol/sdk'; -import { ethers } from 'ethers'; +import { ethers, Wallet } from 'ethers'; export const TEST_PRIVATE_KEY = '0x85dc77260240f78982bdfdfc0a0cb241a85d2f9833468fae7ec362ec7829ce3a'; export const TEST_ADDRESS = '0x9dfB81606Af98a4776a28Ae0Ae30DA3567ae4B98'; -export function generateEthWallet() { - const wallet = ethers.Wallet.createRandom(); +export function generateEthWallet(privateKey?: string) { + const wallet = privateKey + ? new Wallet(privateKey) + : ethers.Wallet.createRandom(); return { privateKey: wallet.privateKey, diff --git a/packages/apps/reputation-oracle/server/test/mock-creators/web3.ts b/packages/apps/reputation-oracle/server/test/mock-creators/web3.ts deleted file mode 100644 index 433326a700..0000000000 --- a/packages/apps/reputation-oracle/server/test/mock-creators/web3.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Provider } from 'ethers'; - -export function createMockProvider(): jest.Mocked< - Pick -> { - return { - getFeeData: jest.fn(), - }; -} diff --git a/packages/apps/reputation-oracle/server/typeorm-migrations-datasource.ts b/packages/apps/reputation-oracle/server/typeorm-migrations-datasource.ts index 7c08b41eed..bc0d308f37 100644 --- a/packages/apps/reputation-oracle/server/typeorm-migrations-datasource.ts +++ b/packages/apps/reputation-oracle/server/typeorm-migrations-datasource.ts @@ -14,6 +14,7 @@ const connectionUrl = process.env.POSTGRES_URL; export default new DataSource({ type: 'postgres', + useUTC: true, ...(connectionUrl ? { url: connectionUrl, diff --git a/yarn.lock b/yarn.lock index 44b7deca97..e63108e219 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19374,6 +19374,11 @@ type-fest@^2.13.0, type-fest@^2.19.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== +type-fest@^4.37.0: + version "4.37.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.37.0.tgz#7cf008bf77b63a33f7ca014fa2a3f09fd69e8937" + integrity sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg== + type-is@^1.6.4, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" From 8b2b0ee9f4a49aa2b334c338883a73a440ece5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:06:19 +0100 Subject: [PATCH 29/29] refactor: update token decimals handling and improve input validation in payment forms (#3198) * refactor: update token decimals handling and improve input validation in payment forms * refactor: enhance payment forms with dynamic decimal handling and validation --- .../components/Jobs/Create/CryptoPayForm.tsx | 108 ++++++++++++------ .../components/Jobs/Create/FiatPayForm.tsx | 39 +++++-- .../src/components/TokenSelect/index.tsx | 14 ++- .../TopUpAccount/CryptoTopUpForm.tsx | 42 +++++-- .../components/TopUpAccount/FiatTopUpForm.tsx | 7 +- .../apps/job-launcher/client/tsconfig.json | 2 +- .../server/src/common/constants/tokens.ts | 59 +++++++--- .../server/src/common/utils/slack.ts | 1 - .../server/src/common/utils/tokens.ts | 14 +++ .../src/modules/job/job.service.spec.ts | 8 +- .../server/src/modules/job/job.service.ts | 45 ++++++-- .../src/modules/payment/payment.controller.ts | 6 +- .../server/src/modules/payment/payment.dto.ts | 18 +++ .../src/modules/payment/payment.service.ts | 14 +-- 14 files changed, 281 insertions(+), 96 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/common/utils/tokens.ts diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx index 95dde721ba..0777ddf7e4 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx @@ -57,6 +57,8 @@ export const CryptoPayForm = ({ const { user } = useAppSelector((state) => state.auth); const [paymentTokenRate, setPaymentTokenRate] = useState(0); const [fundTokenRate, setFundTokenRate] = useState(0); + const [decimals, setDecimals] = useState(6); + const [tokenDecimals, setTokenDecimals] = useState(18); useEffect(() => { const fetchJobLauncherData = async () => { @@ -91,6 +93,19 @@ export const CryptoPayForm = ({ fetchRates(); }, [paymentTokenSymbol, fundTokenSymbol]); + useEffect(() => { + if (amount) { + const [integerPart, decimalPart] = amount.split('.'); + if (decimalPart && decimalPart.length > decimals) { + setAmount(`${integerPart}.${decimalPart.slice(0, decimals)}`); + } + } + }, [decimals, amount]); + + useEffect(() => { + setDecimals(Math.min(tokenDecimals, 6)); + }, [tokenDecimals]); + const { data: jobLauncherFee } = useReadContract({ address: NETWORKS[jobRequest.chainId!]?.kvstoreAddress as Address, abi: KVStoreABI, @@ -113,13 +128,17 @@ export const CryptoPayForm = ({ if (!amount) return 0; const amountDecimal = new Decimal(amount); const feeDecimal = new Decimal(jobLauncherFee as string).div(100); - return Decimal.max(minFeeToken, amountDecimal.mul(feeDecimal)).toNumber(); - }, [amount, minFeeToken, jobLauncherFee]); + return Number( + Decimal.max(minFeeToken, amountDecimal.mul(feeDecimal)).toFixed(decimals), + ); + }, [amount, jobLauncherFee, minFeeToken, decimals]); const totalAmount = useMemo(() => { if (!amount) return 0; - return new Decimal(amount).plus(feeAmount).toNumber(); - }, [amount, feeAmount]); + return Number( + new Decimal(amount).plus(feeAmount).toNumber().toFixed(decimals), + ); + }, [amount, decimals, feeAmount]); const totalUSDAmount = useMemo(() => { if (!totalAmount || !paymentTokenRate) return 0; @@ -135,7 +154,7 @@ export const CryptoPayForm = ({ const fundAmount = useMemo(() => { if (!amount || !conversionRate) return 0; - return new Decimal(amount).mul(conversionRate).toNumber(); + return Number(new Decimal(amount).mul(conversionRate)); }, [amount, conversionRate]); const currentBalance = useMemo(() => { @@ -146,24 +165,19 @@ export const CryptoPayForm = ({ ); }, [user, paymentTokenSymbol]); - const accountAmount = useMemo( - () => new Decimal(currentBalance), - [currentBalance], - ); - const balancePayAmount = useMemo(() => { - if (!payWithAccountBalance) return new Decimal(0); + if (!payWithAccountBalance) return 0; const totalAmountDecimal = new Decimal(totalAmount); - if (totalAmountDecimal.lessThan(accountAmount)) return totalAmountDecimal; - return accountAmount; - }, [payWithAccountBalance, totalAmount, accountAmount]); + if (totalAmountDecimal.lessThan(currentBalance)) return totalAmountDecimal; + return currentBalance; + }, [payWithAccountBalance, totalAmount, currentBalance]); const walletPayAmount = useMemo(() => { - if (!payWithAccountBalance) return new Decimal(totalAmount); + if (!payWithAccountBalance) return totalAmount; const totalAmountDecimal = new Decimal(totalAmount); - if (totalAmountDecimal.lessThan(accountAmount)) return new Decimal(0); - return totalAmountDecimal.minus(accountAmount); - }, [payWithAccountBalance, totalAmount, accountAmount]); + if (totalAmountDecimal.lessThan(currentBalance)) return 0; + return Number(totalAmountDecimal.minus(currentBalance)); + }, [payWithAccountBalance, totalAmount, currentBalance]); const handlePay = async () => { if ( @@ -176,14 +190,14 @@ export const CryptoPayForm = ({ ) { setIsLoading(true); try { - if (walletPayAmount.greaterThan(0)) { + if (walletPayAmount > 0) { const hash = await signer.writeContract({ address: paymentTokenAddress as Address, abi: HMTokenABI, functionName: 'transfer', args: [ jobLauncherAddress, - ethers.parseUnits(walletPayAmount.toString(), 18), + ethers.parseUnits(walletPayAmount.toString(), tokenDecimals), ], }); @@ -294,16 +308,32 @@ export const CryptoPayForm = ({ value={paymentTokenSymbol} label={'Payment token'} labelId={'payment-token'} - onTokenChange={(symbol, address) => { + onTokenChange={(symbol, address, decimals) => { setPaymentTokenSymbol(symbol); setPaymentTokenAddress(address); + setTokenDecimals(decimals); + if (amount) { + const maxDecimals = Math.min(decimals, 6); + const [integerPart, decimalPart] = amount.split('.'); + if (decimalPart && decimalPart.length > maxDecimals) { + setAmount( + `${integerPart}.${decimalPart.slice(0, maxDecimals)}`, + ); + } + } }} /> setAmount(e.target.value as string)} + onChange={(e) => { + let value = e.target.value; + const regex = new RegExp(`^\\d*\\.?\\d{0,${decimals}}$`); + if (regex.test(value)) { + setAmount(value); + } + }} placeholder="Amount" /> @@ -333,8 +363,9 @@ export const CryptoPayForm = ({ > Balance - ~ {currentBalance?.toFixed(2) ?? '0'}{' '} - {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + {paymentTokenSymbol + ? `${Number(currentBalance?.toFixed(6))} ${paymentTokenSymbol?.toUpperCase()}` + : ''} Amount - {amount} {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + {paymentTokenSymbol + ? `${amount} ${paymentTokenSymbol?.toUpperCase()}` + : ''} Fee - ({Number(jobLauncherFee)}%) {feeAmount}{' '} - {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + ({Number(jobLauncherFee)}%){' '} + {paymentTokenSymbol + ? `${Number(feeAmount.toFixed(6))} ${paymentTokenSymbol?.toUpperCase()}` + : ''} Total payment - {totalAmount} {paymentTokenSymbol?.toUpperCase() ?? 'HMT'}{' '} - {`(~${totalUSDAmount.toFixed(2)} USD)`} + {paymentTokenSymbol + ? `${Number(totalAmount?.toFixed(6))} ${paymentTokenSymbol?.toUpperCase()} (~${totalUSDAmount.toFixed(2)} USD)` + : ''} @@ -388,8 +424,9 @@ export const CryptoPayForm = ({ > Balance - {balancePayAmount.toString()}{' '} - {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + {paymentTokenSymbol + ? `${Number(balancePayAmount?.toFixed(6))} ${paymentTokenSymbol?.toUpperCase()}` + : ''} Crypto Wallet - {walletPayAmount.toString()}{' '} - {paymentTokenSymbol?.toUpperCase() ?? 'HMT'} + {paymentTokenSymbol + ? `${Number(walletPayAmount?.toFixed(6))} ${paymentTokenSymbol?.toUpperCase()}` + : ''} @@ -415,7 +453,9 @@ export const CryptoPayForm = ({ > Fund Amount - {fundAmount} {fundTokenSymbol?.toUpperCase() ?? 'HMT'} + {fundTokenSymbol && fundAmount + ? `${Number(fundAmount?.toFixed(6))} ${fundTokenSymbol?.toUpperCase()}` + : ''} diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx index 7c4fe66e58..30644e017a 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx @@ -95,7 +95,7 @@ export const FiatPayForm = ({ useEffect(() => { const fetchRates = async () => { if (tokenSymbol) { - const rate = await getRate(tokenSymbol, 'usd'); + const rate = await getRate('usd', tokenSymbol); setTokenRate(rate); } }; @@ -176,7 +176,6 @@ export const FiatPayForm = ({ (jobLauncherFee as string) || 0, ).div(100); const minFeeDecimal = new Decimal(minFee || 0); - const fundAmountDecimal = amountDecimal.mul(tokenRateDecimal); setFundAmount(fundAmountDecimal.toNumber()); @@ -329,7 +328,12 @@ export const FiatPayForm = ({ variant="outlined" value={amount} type="number" - onChange={(e) => setAmount(e.target.value)} + onChange={(e) => { + let value = e.target.value; + if (/^\d*\.?\d{0,2}$/.test(value)) { + setAmount(value); + } + }} sx={{ mb: 2 }} /> {selectedCard ? ( @@ -402,7 +406,7 @@ export const FiatPayForm = ({ Account Balance {user?.balance && ( - {currentBalance.toFixed(2)} USD + {Number(currentBalance.toFixed(6))} USD )} @@ -420,7 +424,9 @@ export const FiatPayForm = ({ > Amount - {amount} HMT + {amount + ? `${Number(Number(amount)?.toFixed(6))} USD` + : ''} = 0 ? `${Number(jobLauncherFee)}%` : 'loading...'} - ) {feeAmount.toFixed(2)} USD + ){' '} + {amount && feeAmount + ? `${Number(feeAmount?.toFixed(6))} USD` + : ''} Total payment - {totalAmount.toFixed(2)} USD + + {amount && totalAmount + ? `${Number(totalAmount?.toFixed(6))} USD` + : ''} + @@ -457,7 +470,9 @@ export const FiatPayForm = ({ > Balance - {balancePayAmount.toFixed(2)} USD + {amount + ? `${Number(balancePayAmount?.toFixed(6))} USD` + : ''} - {creditCardPayAmount.toFixed(2)} USD + {amount && creditCardPayAmount + ? `${Number(creditCardPayAmount?.toFixed(6))} USD` + : ''} @@ -485,7 +502,9 @@ export const FiatPayForm = ({ > Fund Amount - {fundAmount} {tokenSymbol?.toUpperCase() ?? 'HMT'} + {tokenSymbol && fundAmount + ? `${Number(fundAmount?.toFixed(6))} ${tokenSymbol?.toUpperCase()}` + : ''} diff --git a/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx b/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx index ebfc4089ba..c6ae0cd3b4 100644 --- a/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx +++ b/packages/apps/job-launcher/client/src/components/TokenSelect/index.tsx @@ -13,12 +13,15 @@ import * as paymentService from '../../services/payment'; type TokenSelectProps = SelectProps & { chainId: ChainId; - onTokenChange: (symbol: string, address: string) => void; + onTokenChange: (symbol: string, address: string, decimals: number) => void; }; export const TokenSelect: FC = (props) => { const [availableTokens, setAvailableTokens] = useState<{ - [key: string]: string; + [key: string]: { + address: string; + decimals: number; + }; }>({}); useEffect(() => { @@ -52,8 +55,11 @@ export const TokenSelect: FC = (props) => { {...props} onChange={(e) => { const symbol = e.target.value as string; - const address = availableTokens[symbol]; - props.onTokenChange(symbol, address); + props.onTokenChange( + symbol, + availableTokens[symbol].address, + availableTokens[symbol].decimals, + ); }} > {Object.keys(availableTokens).map((symbol) => { diff --git a/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx b/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx index 9020f14aec..3333630221 100644 --- a/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx +++ b/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx @@ -37,6 +37,7 @@ export const CryptoTopUpForm = () => { const { data: signer } = useWalletClient(); const { data: rate } = useTokenRate(tokenSymbol || 'hmt', 'usd'); const { showError } = useSnackbar(); + const [tokenDecimals, setTokenDecimals] = useState(18); const currentBalance = useMemo(() => { return ( @@ -51,9 +52,22 @@ export const CryptoTopUpForm = () => { return new Decimal(amount).mul(rate); }, [amount, rate]); - const handleTokenChange = (symbol: string, address: string) => { + const handleTokenChange = ( + symbol: string, + address: string, + decimals: number, + ) => { setTokenSymbol(symbol); setTokenAddress(address); + setTokenDecimals(decimals); + + if (amount) { + const maxDecimals = Math.min(decimals, 6); + const [integerPart, decimalPart] = amount.split('.'); + if (decimalPart && decimalPart.length > maxDecimals) { + setAmount(`${integerPart}.${decimalPart.slice(0, maxDecimals)}`); + } + } }; const handleTopUpAccount = async () => { @@ -68,7 +82,7 @@ export const CryptoTopUpForm = () => { functionName: 'transfer', args: [ await paymentService.getOperatorAddress(), - ethers.parseUnits(amount, 18), + ethers.parseUnits(amount.toString(), tokenDecimals), ], }); @@ -140,7 +154,14 @@ export const CryptoTopUpForm = () => { placeholder="Amount" type="number" value={amount} - onChange={(e) => setAmount(e.target.value)} + onChange={(e) => { + let value = e.target.value; + const maxDecimals = Math.min(tokenDecimals, 6); + const regex = new RegExp(`^\\d*\\.?\\d{0,${maxDecimals}}$`); + if (regex.test(value)) { + setAmount(value); + } + }} /> @@ -166,8 +187,11 @@ export const CryptoTopUpForm = () => { > Balance - {currentBalance} {tokenSymbol?.toUpperCase()} (~ - {((currentBalance ?? 0) * rate).toFixed(2)} USD) + {tokenSymbol + ? `${Number(currentBalance.toFixed(6))} ${tokenSymbol?.toUpperCase()} (~ + ${Number(((currentBalance ?? 0) * rate).toFixed(2))} + USD)` + : ''} { {tokenSymbol?.toUpperCase()} Price - {rate?.toFixed(2)} USD + {rate ? `${rate.toFixed(2)} USD` : ''} @@ -191,8 +215,10 @@ export const CryptoTopUpForm = () => { > You receive - {amount} {tokenSymbol?.toUpperCase()} (~ - {totalAmount.toFixed(2)} USD) + {amount && tokenSymbol + ? `${amount} ${tokenSymbol?.toUpperCase()} (~ + ${totalAmount.toFixed(2)} USD)` + : ''} diff --git a/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx b/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx index c5bb0dfdcb..3683408997 100644 --- a/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx +++ b/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx @@ -145,7 +145,12 @@ export const FiatTopUpForm = () => { variant="outlined" value={amount} type="number" - onChange={(e) => setAmount(e.target.value)} + onChange={(e) => { + let value = e.target.value; + if (/^\d*\.?\d{0,2}$/.test(value)) { + setAmount(value); + } + }} sx={{ mb: 2 }} /> {selectedCard ? ( diff --git a/packages/apps/job-launcher/client/tsconfig.json b/packages/apps/job-launcher/client/tsconfig.json index df9ed0dbbc..0a74b5d057 100644 --- a/packages/apps/job-launcher/client/tsconfig.json +++ b/packages/apps/job-launcher/client/tsconfig.json @@ -18,7 +18,7 @@ "resolveJsonModule": true, "downlevelIteration": true, "baseUrl": ".", - "types": ["node", "jest", "@testing-library/jest-dom"] + "types": ["node", "jest"] }, "include": ["src", "tests"] } diff --git a/packages/apps/job-launcher/server/src/common/constants/tokens.ts b/packages/apps/job-launcher/server/src/common/constants/tokens.ts index 103c7768ad..8a3856f7e8 100644 --- a/packages/apps/job-launcher/server/src/common/constants/tokens.ts +++ b/packages/apps/job-launcher/server/src/common/constants/tokens.ts @@ -1,37 +1,62 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { ChainId, NETWORKS } from '@human-protocol/sdk'; import { EscrowFundToken } from '../enums/job'; export const TOKEN_ADDRESSES: { [chainId in ChainId]?: { - [token in EscrowFundToken]?: string; + [token in EscrowFundToken]?: { + address: string; + decimals: number; + }; }; } = { [ChainId.MAINNET]: { - [EscrowFundToken.HMT]: NETWORKS[ChainId.MAINNET]?.hmtAddress, - // [EscrowFundToken.USDT]: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - // [EscrowFundToken.USDC]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606EB48', + [EscrowFundToken.HMT]: { + address: NETWORKS[ChainId.MAINNET]!.hmtAddress, + decimals: 18, + }, + // [EscrowFundToken.USDT]: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, + // [EscrowFundToken.USDC]: { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606EB48', decimals: 6 }, }, [ChainId.SEPOLIA]: { - [EscrowFundToken.HMT]: NETWORKS[ChainId.SEPOLIA]?.hmtAddress, - // [EscrowFundToken.USDT]: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', - // [EscrowFundToken.USDC]: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + [EscrowFundToken.HMT]: { + address: NETWORKS[ChainId.SEPOLIA]!.hmtAddress, + decimals: 18, + }, + // [EscrowFundToken.USDT]: { address: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', decimals: 6 }, + // [EscrowFundToken.USDC]: { address: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', decimals: 6 }, }, [ChainId.BSC_MAINNET]: { - [EscrowFundToken.HMT]: NETWORKS[ChainId.BSC_MAINNET]?.hmtAddress, - // [EscrowFundToken.USDT]: '0x55d398326f99059fF775485246999027B3197955', - // [EscrowFundToken.USDC]: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + [EscrowFundToken.HMT]: { + address: NETWORKS[ChainId.BSC_MAINNET]!.hmtAddress, + decimals: 18, + }, + // [EscrowFundToken.USDT]: { address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 }, + // [EscrowFundToken.USDC]: { address: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', decimals: 18 }, }, [ChainId.POLYGON]: { - [EscrowFundToken.HMT]: NETWORKS[ChainId.POLYGON]?.hmtAddress, - // [EscrowFundToken.USDT]: '0x3813e82e6f7098b9583FC0F33a962D02018B6803', - // [EscrowFundToken.USDC]: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + [EscrowFundToken.HMT]: { + address: NETWORKS[ChainId.POLYGON]!.hmtAddress, + decimals: 18, + }, + // [EscrowFundToken.USDT]: { address: '0x3813e82e6f7098b9583FC0F33a962D02018B6803', decimals: 6 }, + // [EscrowFundToken.USDC]: { address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', decimals: 6 }, }, [ChainId.POLYGON_AMOY]: { - [EscrowFundToken.HMT]: NETWORKS[ChainId.POLYGON_AMOY]?.hmtAddress, - // [EscrowFundToken.USDC]: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', + [EscrowFundToken.HMT]: { + address: NETWORKS[ChainId.POLYGON_AMOY]!.hmtAddress, + decimals: 18, + }, + // [EscrowFundToken.USDC]: { address: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', decimals: 6 }, }, [ChainId.LOCALHOST]: { - [EscrowFundToken.HMT]: NETWORKS[ChainId.LOCALHOST]?.hmtAddress, - // [EscrowFundToken.USDC]: '0x09635F643e140090A9A8Dcd712eD6285858ceBef', + [EscrowFundToken.HMT]: { + address: NETWORKS[ChainId.LOCALHOST]!.hmtAddress, + decimals: 18, + }, + // [EscrowFundToken.USDC]: { + // address: '0xa85233C63b9Ee964Add6F2cffe00Fd84eb32338f', + // decimals: 6, + // }, }, }; diff --git a/packages/apps/job-launcher/server/src/common/utils/slack.ts b/packages/apps/job-launcher/server/src/common/utils/slack.ts index e4d897ad8f..e74d4fd02f 100644 --- a/packages/apps/job-launcher/server/src/common/utils/slack.ts +++ b/packages/apps/job-launcher/server/src/common/utils/slack.ts @@ -12,7 +12,6 @@ export async function sendSlackNotification( }; if (!webhookUrl || webhookUrl === 'disabled') { - logger.log('Slack notification (mocked):', payload); return true; // Simulate success to avoid unnecessary errors } diff --git a/packages/apps/job-launcher/server/src/common/utils/tokens.ts b/packages/apps/job-launcher/server/src/common/utils/tokens.ts new file mode 100644 index 0000000000..0fb85f03a8 --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/utils/tokens.ts @@ -0,0 +1,14 @@ +import { ChainId } from '@human-protocol/sdk'; +import { TOKEN_ADDRESSES } from '../constants/tokens'; +import { EscrowFundToken } from '../enums/job'; + +export function getTokenDecimals( + chainId: ChainId, + symbol: EscrowFundToken, + defaultDecimals = 6, +): number { + return Math.min( + TOKEN_ADDRESSES[chainId]?.[symbol]?.decimals ?? defaultDecimals, + defaultDecimals, + ); +} diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index 52a5e4751e..a99f024c30 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -231,9 +231,11 @@ describe('JobService', () => { manifestHash: MOCK_FILE_HASH, requestType: JobRequestType.FORTUNE, fee: expect.any(Number), - fundAmount: mul( - mul(fortuneJobDto.paymentAmount, paymentToUsdRate), - usdToTokenRate, + fundAmount: Number( + mul( + mul(fortuneJobDto.paymentAmount, paymentToUsdRate), + usdToTokenRate, + ).toFixed(6), ), status: JobStatus.PAID, waitUntil: expect.any(Date), diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 2b0ff795bb..5f5e0b2761 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -117,6 +117,7 @@ import { WhitelistService } from '../whitelist/whitelist.service'; import { UserEntity } from '../user/user.entity'; import { RoutingProtocolService } from '../routing-protocol/routing-protocol.service'; import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; +import { getTokenDecimals } from '../../common/utils/tokens'; @Injectable() export class JobService { @@ -799,20 +800,44 @@ export class JobService { dto.escrowFundToken, ); - const paymentCurrencyFee = max( - div(this.serverConfigService.minimumFeeUsd, paymentCurrencyRate), - mul(div(feePercentage, 100), dto.paymentAmount), + const paymentTokenDecimals = getTokenDecimals( + chainId, + dto.paymentCurrency as EscrowFundToken, + ); + + const fundTokenDecimals = getTokenDecimals( + chainId, + dto.escrowFundToken as EscrowFundToken, + ); + + const paymentCurrencyFee = Number( + max( + div(this.serverConfigService.minimumFeeUsd, paymentCurrencyRate), + mul(div(feePercentage, 100), dto.paymentAmount), + ).toFixed(paymentTokenDecimals), + ); + const totalPaymentAmount = Number( + add(dto.paymentAmount, paymentCurrencyFee).toFixed(paymentTokenDecimals), ); - const totalPaymentAmount = add(dto.paymentAmount, paymentCurrencyFee); const fundTokenFee = dto.paymentCurrency === dto.escrowFundToken ? paymentCurrencyFee - : mul(mul(paymentCurrencyFee, paymentCurrencyRate), fundTokenRate); + : Number( + mul( + mul(paymentCurrencyFee, paymentCurrencyRate), + fundTokenRate, + ).toFixed(fundTokenDecimals), + ); const fundTokenAmount = dto.paymentCurrency === dto.escrowFundToken ? dto.paymentAmount - : mul(mul(dto.paymentAmount, paymentCurrencyRate), fundTokenRate); + : Number( + mul( + mul(dto.paymentAmount, paymentCurrencyRate), + fundTokenRate, + ).toFixed(fundTokenDecimals), + ); // Select oracles if (!reputationOracle || !exchangeOracle || !recordingOracle) { @@ -999,7 +1024,7 @@ export class JobService { const escrowAddress = await escrowClient.createEscrow( (TOKEN_ADDRESSES[jobEntity.chainId as ChainId] ?? {})[ jobEntity.token as EscrowFundToken - ]!, + ]!.address, getTrustedHandlers(), jobEntity.userId.toString(), { @@ -1067,9 +1092,13 @@ export class JobService { const escrowClient = await EscrowClient.build(signer); + const token = (TOKEN_ADDRESSES[jobEntity.chainId as ChainId] ?? {})[ + jobEntity.token as EscrowFundToken + ]!; + const weiAmount = ethers.parseUnits( jobEntity.fundAmount.toString(), - 'ether', + token.decimals, ); await escrowClient.fund(jobEntity.escrowAddress, weiAmount, { gasPrice: await this.web3Service.calculateGasPrice(jobEntity.chainId), diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts index 01141d50b1..0cc824795b 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts @@ -44,9 +44,11 @@ import { PaymentFiatConfirmDto, PaymentFiatCreateDto, PaymentMethodIdDto, + TokensResponseDto, UserBalanceDto, } from './payment.dto'; import { PaymentService } from './payment.service'; +import { TokenDto } from './payment.dto'; @ApiBearerAuth() @UseGuards(JwtAuthGuard) @@ -440,7 +442,7 @@ export class PaymentController { @ApiResponse({ status: 200, description: 'Tokens retrieved successfully', - type: [Object], + type: [TokenDto], }) @ApiResponse({ status: 400, @@ -449,7 +451,7 @@ export class PaymentController { @Get('/tokens/:chainId') public async getTokens( @Param('chainId') chainId: ChainId, - ): Promise<{ [key: string]: string }> { + ): Promise { const tokens = TOKEN_ADDRESSES[chainId]; if (!tokens) { throw new ControlledError( diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index a357c042f7..c713700153 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -242,3 +242,21 @@ export class UserBalanceDto { @ApiProperty({ name: 'total_usd_amount' }) totalUsdAmount: number; } + +export class TokenDto { + @ApiProperty({ + description: 'The address of the token contract', + example: '0x1234567890abcdef1234567890abcdef12345678', + }) + address: string; + + @ApiProperty({ + description: 'The number of decimals used by the token', + example: 18, + }) + decimals: number; +} + +export class TokensResponseDto { + [key: string]: TokenDto; +} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 34db480eba..9e20285089 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import Stripe from 'stripe'; -import { ethers } from 'ethers'; +import { ethers, formatUnits } from 'ethers'; import { ErrorPayment } from '../../common/constants/errors'; import { PaymentRepository } from './payment.repository'; import { @@ -355,19 +355,19 @@ export class PaymentService { } const tokenId = (await tokenContract.symbol()).toLowerCase(); - const amount = Number(ethers.formatEther(transaction.logs[0].data)); + const token = TOKEN_ADDRESSES[dto.chainId]?.[tokenId as EscrowFundToken]; - if ( - TOKEN_ADDRESSES[dto.chainId]?.[tokenId as EscrowFundToken] !== - tokenAddress || - !CoingeckoTokenId[tokenId] - ) { + if (token?.address !== tokenAddress || !CoingeckoTokenId[tokenId]) { throw new ControlledError( ErrorPayment.UnsupportedToken, HttpStatus.CONFLICT, ); } + const amount = Number( + formatUnits(transaction.logs[0].data, token.decimals), + ); + const paymentEntity = await this.paymentRepository.findOneByTransaction( transaction.hash, dto.chainId,
+ + + + - - - -