diff --git a/fullstack/task/.pnp.cjs b/fullstack/task/.pnp.cjs index 6902d2a6..48be9982 100644 --- a/fullstack/task/.pnp.cjs +++ b/fullstack/task/.pnp.cjs @@ -213,8 +213,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@graphql-typed-document-node/core", "virtual:e0b19fa36a3d016aae5fd6ca0173f5f800f0c41366739f1b19751d304488f85d6329749c06fde3953bd338b66d91a2ee5062a4743ee1b4d440cd73f171022bb9#npm:3.2.0"],\ ["@types/graphql", null],\ ["@types/graphql-ws", null],\ - ["@types/react", "npm:18.0.17"],\ - ["@types/react-dom", "npm:18.0.6"],\ + ["@types/react", "npm:18.2.22"],\ + ["@types/react-dom", "npm:18.2.22"],\ ["@types/subscriptions-transport-ws", null],\ ["@wry/context", "npm:0.7.3"],\ ["@wry/equality", "npm:0.5.6"],\ @@ -3606,16 +3606,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@types/react", [\ - ["npm:18.0.17", {\ - "packageLocation": "./.yarn/cache/@types-react-npm-18.0.17-c4860cbae4-18cae64f5b.zip/node_modules/@types/react/",\ - "packageDependencies": [\ - ["@types/react", "npm:18.0.17"],\ - ["@types/prop-types", "npm:15.7.7"],\ - ["@types/scheduler", "npm:0.16.4"],\ - ["csstype", "npm:3.1.2"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:18.2.22", {\ "packageLocation": "./.yarn/cache/@types-react-npm-18.2.22-bd0a04edf5-44289523da.zip/node_modules/@types/react/",\ "packageDependencies": [\ @@ -3628,10 +3618,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@types/react-dom", [\ - ["npm:18.0.6", {\ - "packageLocation": "./.yarn/cache/@types-react-dom-npm-18.0.6-55949dcf84-db571047af.zip/node_modules/@types/react-dom/",\ + ["npm:18.2.22", {\ + "packageLocation": "./.yarn/cache/@types-react-dom-npm-18.2.22-5cfb6052f5-83bb3ddcd7.zip/node_modules/@types/react-dom/",\ "packageDependencies": [\ - ["@types/react-dom", "npm:18.0.6"],\ + ["@types/react-dom", "npm:18.2.22"],\ ["@types/react", "npm:18.2.22"]\ ],\ "linkType": "HARD"\ @@ -3721,6 +3711,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@types/xml2js", [\ + ["npm:0.4.14", {\ + "packageLocation": "./.yarn/cache/@types-xml2js-npm-0.4.14-595cc9c3cf-df9f106b99.zip/node_modules/@types/xml2js/",\ + "packageDependencies": [\ + ["@types/xml2js", "npm:0.4.14"],\ + ["@types/node", "npm:20.7.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/yargs", [\ ["npm:17.0.25", {\ "packageLocation": "./.yarn/cache/@types-yargs-npm-17.0.25-6c76584c67-ef57926de5.zip/node_modules/@types/yargs/",\ @@ -5711,8 +5711,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@dishboard/eslint-config", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#workspace:packages/tools/dishboard-eslint-config"],\ ["@dishboard/jest-config", "workspace:packages/tools/dishboard-jest-config"],\ ["@types/jest", "npm:28.1.8"],\ - ["@types/react", "npm:18.0.17"],\ - ["@types/react-dom", "npm:18.0.6"],\ + ["@types/react", "npm:18.2.22"],\ + ["@types/react-dom", "npm:18.2.22"],\ ["@types/styled-components", "npm:5.1.28"],\ ["@vitejs/plugin-react", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.1.0"],\ ["cross-env", "npm:7.0.3"],\ @@ -12073,7 +12073,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageLocation": "./.yarn/__virtual__/react-dom-virtual-0c6baa8020/0/cache/react-dom-npm-18.2.0-dd675bca1c-7d323310be.zip/node_modules/react-dom/",\ "packageDependencies": [\ ["react-dom", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:18.2.0"],\ - ["@types/react", "npm:18.0.17"],\ + ["@types/react", "npm:18.2.22"],\ ["loose-envify", "npm:1.4.0"],\ ["react", "npm:18.2.0"],\ ["scheduler", "npm:0.23.0"]\ @@ -12601,6 +12601,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/passport-jwt", "npm:3.0.8"],\ ["@types/supertest", "npm:2.0.11"],\ ["@types/ws", "npm:8.5.4"],\ + ["@types/xml2js", "npm:0.4.14"],\ ["apollo-server-core", "virtual:2499dbb93d824027565d71b0716c4fb8b548ad61955d0a0286bfb3c5b4058e227894b6691d96808c00f576db14870018375210362c26ee321ea99fd6ed041c74#npm:3.11.1"],\ ["apollo-server-express", "virtual:2499dbb93d824027565d71b0716c4fb8b548ad61955d0a0286bfb3c5b4058e227894b6691d96808c00f576db14870018375210362c26ee321ea99fd6ed041c74#npm:3.10.2"],\ ["axios", "npm:1.3.4"],\ @@ -12629,7 +12630,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["supertest", "npm:6.1.3"],\ ["ts-jest", "virtual:2499dbb93d824027565d71b0716c4fb8b548ad61955d0a0286bfb3c5b4058e227894b6691d96808c00f576db14870018375210362c26ee321ea99fd6ed041c74#npm:28.0.8"],\ ["typeorm", "virtual:2499dbb93d824027565d71b0716c4fb8b548ad61955d0a0286bfb3c5b4058e227894b6691d96808c00f576db14870018375210362c26ee321ea99fd6ed041c74#npm:0.3.10"],\ - ["ws", "virtual:2499dbb93d824027565d71b0716c4fb8b548ad61955d0a0286bfb3c5b4058e227894b6691d96808c00f576db14870018375210362c26ee321ea99fd6ed041c74#npm:8.13.0"]\ + ["ws", "virtual:2499dbb93d824027565d71b0716c4fb8b548ad61955d0a0286bfb3c5b4058e227894b6691d96808c00f576db14870018375210362c26ee321ea99fd6ed041c74#npm:8.13.0"],\ + ["xml2js", "npm:0.6.2"]\ ],\ "linkType": "SOFT"\ }]\ @@ -13138,8 +13140,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@emotion/stylis", "npm:0.8.5"],\ ["@emotion/unitless", "npm:0.7.5"],\ ["@types/babel__core", null],\ - ["@types/react", "npm:18.0.17"],\ - ["@types/react-dom", "npm:18.0.6"],\ + ["@types/react", "npm:18.2.22"],\ + ["@types/react-dom", "npm:18.2.22"],\ ["@types/react-is", null],\ ["babel-plugin-styled-components", "virtual:01823a788680823ab93228820416af35cf1c2251be3ca19ce626a580e17535febac1fae25aa276bf4d1fbb77946f667ddd8de8b0f887319ae0c45f39b83f5f6d#npm:2.1.4"],\ ["css-to-react-native", "npm:3.2.0"],\ @@ -14658,6 +14660,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["xmlbuilder", "npm:11.0.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:0.6.2", {\ + "packageLocation": "./.yarn/cache/xml2js-npm-0.6.2-64cd781d74-458a838061.zip/node_modules/xml2js/",\ + "packageDependencies": [\ + ["xml2js", "npm:0.6.2"],\ + ["sax", "npm:1.2.4"],\ + ["xmlbuilder", "npm:11.0.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["xmlbuilder", [\ diff --git a/fullstack/task/.yarn/cache/@types-react-dom-npm-18.0.6-55949dcf84-db571047af.zip b/fullstack/task/.yarn/cache/@types-react-dom-npm-18.0.6-55949dcf84-db571047af.zip deleted file mode 100644 index 59b07ce8..00000000 Binary files a/fullstack/task/.yarn/cache/@types-react-dom-npm-18.0.6-55949dcf84-db571047af.zip and /dev/null differ diff --git a/fullstack/task/.yarn/cache/@types-react-dom-npm-18.2.22-5cfb6052f5-83bb3ddcd7.zip b/fullstack/task/.yarn/cache/@types-react-dom-npm-18.2.22-5cfb6052f5-83bb3ddcd7.zip new file mode 100644 index 00000000..1a2cb360 Binary files /dev/null and b/fullstack/task/.yarn/cache/@types-react-dom-npm-18.2.22-5cfb6052f5-83bb3ddcd7.zip differ diff --git a/fullstack/task/.yarn/cache/@types-react-npm-18.0.17-c4860cbae4-18cae64f5b.zip b/fullstack/task/.yarn/cache/@types-react-npm-18.0.17-c4860cbae4-18cae64f5b.zip deleted file mode 100644 index d640cb06..00000000 Binary files a/fullstack/task/.yarn/cache/@types-react-npm-18.0.17-c4860cbae4-18cae64f5b.zip and /dev/null differ diff --git a/fullstack/task/.yarn/cache/@types-xml2js-npm-0.4.14-595cc9c3cf-df9f106b99.zip b/fullstack/task/.yarn/cache/@types-xml2js-npm-0.4.14-595cc9c3cf-df9f106b99.zip new file mode 100644 index 00000000..d6fdcc1c Binary files /dev/null and b/fullstack/task/.yarn/cache/@types-xml2js-npm-0.4.14-595cc9c3cf-df9f106b99.zip differ diff --git a/fullstack/task/.yarn/cache/xml2js-npm-0.6.2-64cd781d74-458a838061.zip b/fullstack/task/.yarn/cache/xml2js-npm-0.6.2-64cd781d74-458a838061.zip new file mode 100644 index 00000000..f264eda1 Binary files /dev/null and b/fullstack/task/.yarn/cache/xml2js-npm-0.6.2-64cd781d74-458a838061.zip differ diff --git a/fullstack/task/packages/client/package.json b/fullstack/task/packages/client/package.json index 819a4b34..2e262a58 100644 --- a/fullstack/task/packages/client/package.json +++ b/fullstack/task/packages/client/package.json @@ -1,38 +1,38 @@ -{ - "name": "client", - "private": true, - "version": "1.0.0", - "dependencies": { - "@apollo/client": "3.7.10", - "graphql": "16.6.0", - "graphql-tag": "2.12.6", - "graphql-ws": "5.12.0", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-is": "18.2.0", - "styled-components": "5.3.9", - "vite-tsconfig-paths": "^4.0.7" - }, - "devDependencies": { - "@babel/core": "7.23.0", - "@dishboard/eslint-config": "1.0.0", - "@dishboard/jest-config": "1.0.0", - "@types/jest": "28.1.8", - "@types/react": "18.0.17", - "@types/react-dom": "18.0.6", - "@types/styled-components": "^5.1.26", - "@vitejs/plugin-react": "2.1.0", - "cross-env": "7.0.3", - "eslint-import-resolver-typescript": "3.5.3", - "eslint-plugin-import": "2.27.5", - "eslint-plugin-react": "7.31.8", - "jest": "28.1.3", - "ts-jest": "28.0.8", - "vite": "3.1.0" - }, - "peerDependencies": { - "eslint": "*", - "prettier": "*", - "typescript": "*" - } -} +{ + "name": "client", + "private": true, + "version": "1.0.0", + "dependencies": { + "@apollo/client": "3.7.10", + "graphql": "16.6.0", + "graphql-tag": "2.12.6", + "graphql-ws": "5.12.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-is": "18.2.0", + "styled-components": "5.3.9", + "vite-tsconfig-paths": "^4.0.7" + }, + "devDependencies": { + "@babel/core": "7.23.0", + "@dishboard/eslint-config": "1.0.0", + "@dishboard/jest-config": "1.0.0", + "@types/jest": "28.1.8", + "@types/react": "18.2.22", + "@types/react-dom": "18.2.22", + "@types/styled-components": "^5.1.26", + "@vitejs/plugin-react": "2.1.0", + "cross-env": "7.0.3", + "eslint-import-resolver-typescript": "3.5.3", + "eslint-plugin-import": "2.27.5", + "eslint-plugin-react": "7.31.8", + "jest": "28.1.3", + "ts-jest": "28.0.8", + "vite": "3.1.0" + }, + "peerDependencies": { + "eslint": "*", + "prettier": "*", + "typescript": "*" + } +} diff --git a/fullstack/task/packages/client/src/App.tsx b/fullstack/task/packages/client/src/App.tsx index f62d2a02..b2c1cd1f 100644 --- a/fullstack/task/packages/client/src/App.tsx +++ b/fullstack/task/packages/client/src/App.tsx @@ -1,5 +1,7 @@ +import RatesTable from './components/RatesTable/RatesTable'; + function App() { - return

TODO

; + return ; } export default App; diff --git a/fullstack/task/packages/client/src/components/Loader/Loader.styles.ts b/fullstack/task/packages/client/src/components/Loader/Loader.styles.ts new file mode 100644 index 00000000..1a13d591 --- /dev/null +++ b/fullstack/task/packages/client/src/components/Loader/Loader.styles.ts @@ -0,0 +1,20 @@ +import styled, { keyframes } from 'styled-components'; + +const rotate = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; + +export const StyledLoader = styled.div` + border: 16px solid #f3f3f3; + border-radius: 50%; + border-top: 16px solid #3498db; + width: 120px; + height: 120px; + -webkit-animation: spin 2s linear infinite; /* Safari */ + animation: ${rotate} 2s linear infinite; +`; diff --git a/fullstack/task/packages/client/src/components/Loader/Loader.tsx b/fullstack/task/packages/client/src/components/Loader/Loader.tsx new file mode 100644 index 00000000..a2652673 --- /dev/null +++ b/fullstack/task/packages/client/src/components/Loader/Loader.tsx @@ -0,0 +1,5 @@ +import { StyledLoader } from './Loader.styles'; + +export default function Loader() { + return ; +} diff --git a/fullstack/task/packages/client/src/components/RatesTable/RatesTable.styles.tsx b/fullstack/task/packages/client/src/components/RatesTable/RatesTable.styles.tsx new file mode 100644 index 00000000..dccbb4c7 --- /dev/null +++ b/fullstack/task/packages/client/src/components/RatesTable/RatesTable.styles.tsx @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +export const RatesWrapper = styled.div` + display: flex; + width: 100%; + height: 100vh; + justify-content: center; + align-items: center; + flex-direction: column; +`; +export const RatesTableContainer = styled.div` + padding: 10px; + width: 80%; + overflow-x: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border-radius: 8px; + height: 60vh; +`; diff --git a/fullstack/task/packages/client/src/components/RatesTable/RatesTable.tsx b/fullstack/task/packages/client/src/components/RatesTable/RatesTable.tsx new file mode 100644 index 00000000..08544fab --- /dev/null +++ b/fullstack/task/packages/client/src/components/RatesTable/RatesTable.tsx @@ -0,0 +1,34 @@ +import { gql, useQuery } from '@apollo/client'; +import { useMemo } from 'react'; +import { GET_EXCHANGE_RATES } from 'src/graphql/rates/queries'; +import { minutesPassed } from 'src/utils'; +import Loader from '../Loader/Loader'; +import Table from '../Table/Table'; +import { rateColumns } from './columns'; +import { RatesTableContainer, RatesWrapper } from './RatesTable.styles'; + +export default function RatesTable() { + const { data, loading } = useQuery(GET_EXCHANGE_RATES); + const rates = data?.exchangeRates.rates; + const lastCachedAt = data?.exchangeRates.lastCachedAt; + const date = new Date(lastCachedAt); + const columns = useMemo(() => { + return rateColumns; + }, []); + return ( + + {loading ? ( + + ) : ( + <> + + + +

+ {date.toLocaleString()} (Last cached at {minutesPassed(date)}) +

+ + )} + + ); +} diff --git a/fullstack/task/packages/client/src/components/RatesTable/columns.ts b/fullstack/task/packages/client/src/components/RatesTable/columns.ts new file mode 100644 index 00000000..d5ef41dc --- /dev/null +++ b/fullstack/task/packages/client/src/components/RatesTable/columns.ts @@ -0,0 +1,22 @@ +export const rateColumns = [ + { + header: 'Code', + key: 'code', + }, + { + header: 'Currency', + key: 'currency', + }, + { + header: 'Amount', + key: 'amount', + }, + { + header: 'Rate', + key: 'exchangeRate', + }, + { + header: 'Country', + key: 'country', + }, +]; diff --git a/fullstack/task/packages/client/src/components/Table/Table.styles.ts b/fullstack/task/packages/client/src/components/Table/Table.styles.ts new file mode 100644 index 00000000..b878d5d1 --- /dev/null +++ b/fullstack/task/packages/client/src/components/Table/Table.styles.ts @@ -0,0 +1,35 @@ +import styled from 'styled-components'; + +export const StyledTable = styled.table` + width: 100%; + border-collapse: collapse; /* Removes double borders */ + font-family: Arial, sans-serif; + font-size: 14px; +`; + +export const TableHeader = styled.th` + background-color: #f8f9fa; + color: #333; + text-align: left; + padding: 12px 15px; + border-bottom: 2px solid #ddd; + text-transform: uppercase; + letter-spacing: 0.05em; +`; + +export const TableRow = styled.tr` + &:nth-child(even) { + background-color: #f4f6f9; + } + + &:hover { + background-color: #e9ecef; + transition: background-color 0.2s ease; + } +`; + +export const TableCell = styled.td` + padding: 12px 15px; + border-bottom: 1px solid #eee; + color: #555; +`; diff --git a/fullstack/task/packages/client/src/components/Table/Table.tsx b/fullstack/task/packages/client/src/components/Table/Table.tsx new file mode 100644 index 00000000..ed861cb9 --- /dev/null +++ b/fullstack/task/packages/client/src/components/Table/Table.tsx @@ -0,0 +1,40 @@ +import { StyledTable, TableCell, TableHeader, TableRow } from './Table.styles'; + +export interface TableProps> { + data: TData[]; + columns: TableColumn[]; +} + +export interface TableColumn> { + header: string; + key: keyof TData; + render?: (row: TData) => React.ReactNode; +} + +export default function Table>({ + columns, + data = [], +}: TableProps) { + return ( + + + + {columns.map((column) => ( + {column.header} + ))} + + + + {data.map((row, rowIndex) => ( + + {columns.map((column) => ( + + {column.render ? column.render(row) : row[column.key]} + + ))} + + ))} + + + ); +} diff --git a/fullstack/task/packages/client/src/graphql/rates/queries.ts b/fullstack/task/packages/client/src/graphql/rates/queries.ts new file mode 100644 index 00000000..37fa9bb5 --- /dev/null +++ b/fullstack/task/packages/client/src/graphql/rates/queries.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const GET_EXCHANGE_RATES = gql` + query GetExchangeRates { + exchangeRates { + rates { + code + country + currency + exchangeRate + amount + } + lastCachedAt + } + } +`; diff --git a/fullstack/task/packages/client/src/main.tsx b/fullstack/task/packages/client/src/main.tsx index ef5f39c2..50cfc871 100644 --- a/fullstack/task/packages/client/src/main.tsx +++ b/fullstack/task/packages/client/src/main.tsx @@ -4,7 +4,7 @@ import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; import App from './App'; const client = new ApolloClient({ - uri: 'http://localhost:4000/graphql', + uri: 'http://localhost:4001/graphql', cache: new InMemoryCache(), }); @@ -13,5 +13,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + , ); diff --git a/fullstack/task/packages/client/src/utils/index.ts b/fullstack/task/packages/client/src/utils/index.ts new file mode 100644 index 00000000..06ac143a --- /dev/null +++ b/fullstack/task/packages/client/src/utils/index.ts @@ -0,0 +1,21 @@ +export const minutesPassed = (lastCachedAt: Date) => { + const currentTime = new Date(); + const diffInMilliseconds = Math.abs(currentTime.getTime() - lastCachedAt.getTime()); + + const secondsPassed = Math.round(diffInMilliseconds / 1000); + + if (secondsPassed < 60) { + if (secondsPassed < 10) { + return `just now`; + } + return `${secondsPassed} seconds ago`; + } + + const minutesPassed = Math.round(secondsPassed / 60); + + if (minutesPassed === 1) { + return `1 minute ago`; + } + + return `${minutesPassed} minutes ago`; +}; diff --git a/fullstack/task/packages/server/.env b/fullstack/task/packages/server/.env index 32fba8c8..0fac33b0 100644 --- a/fullstack/task/packages/server/.env +++ b/fullstack/task/packages/server/.env @@ -9,3 +9,5 @@ GQL_PLAYGROUND=true GQL_INTROSPECTION=true PORT=4001 + +EXCHANGE_RATES_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml diff --git a/fullstack/task/packages/server/.eslintrc.js b/fullstack/task/packages/server/.eslintrc.js index 0a89e2b5..4aca1835 100644 --- a/fullstack/task/packages/server/.eslintrc.js +++ b/fullstack/task/packages/server/.eslintrc.js @@ -1,22 +1,22 @@ -const path = require('path'); - -module.exports = { - extends: ['@dishboard'], - parserOptions: { - project: path.resolve(process.cwd(), './tsconfig.json'), - tsconfigRootDir: process.cwd(), - sourceType: 'module', - }, - env: { - node: true, - jest: true, - }, - ignorePatterns: ['**/migrations/*.ts'], - settings: { - 'import/resolver': { - typescript: { - project: path.resolve(process.cwd(), './tsconfig.json'), - }, - }, - }, -}; +const path = require('path'); + +module.exports = { + extends: ['@dishboard'], + parserOptions: { + project: path.resolve(process.cwd(), './tsconfig.json'), + tsconfigRootDir: process.cwd(), + sourceType: 'module', + }, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['**/migrations/*.ts'], + settings: { + 'import/resolver': { + typescript: { + project: path.resolve(process.cwd(), './tsconfig.json'), + }, + }, + }, +}; diff --git a/fullstack/task/packages/server/README.md b/fullstack/task/packages/server/README.md index b62438f2..7b23a079 100644 --- a/fullstack/task/packages/server/README.md +++ b/fullstack/task/packages/server/README.md @@ -1,46 +1,46 @@ -# dishboard backend - -Contains the code for the dishboard backend. - -## Installation - -Follow these steps for the first-time setup: - -### 1. Install yarn - -Follow the instructions from [yarn's website](https://classic.yarnpkg.com/lang/en/docs/install) to install the `yarn` CLI. - -### 2. Install dependencies - -Run `yarn install`. - -### 3. Install docker - -Create a [Docker account](https://hub.docker.com/signup). - -Install [Docker](https://docs.docker.com/install/). - -Run `docker login`. - -### 4. Create and start the database - -- If this is your first-time setup - run `yarn server db-create` (from the root workspace `fullstack/task`). This will create (and overwrite!) a docker container for the database. -- Otherwise, run the docker container that was created during the first-time setup. - -### 5. Start the server - -Run `yarn server start` from the root workspace. - -## Updating database entities - -The databasee synchronization is turned off by default. Whenever you update an entity and want to propagate that change to the database you need to run a migration. - -First, generate the migration. Make sure to use a descriptive name, like in a commit message. - -``` -yarn server migration -c generate --name migration-name -``` - -This will create a file describing all the changes to the database, s.t. it can be replicated or reverted easily. - -All pending migrations are ran automatically when the server starts. +# dishboard backend + +Contains the code for the dishboard backend. + +## Installation + +Follow these steps for the first-time setup: + +### 1. Install yarn + +Follow the instructions from [yarn's website](https://classic.yarnpkg.com/lang/en/docs/install) to install the `yarn` CLI. + +### 2. Install dependencies + +Run `yarn install`. + +### 3. Install docker + +Create a [Docker account](https://hub.docker.com/signup). + +Install [Docker](https://docs.docker.com/install/). + +Run `docker login`. + +### 4. Create and start the database + +- If this is your first-time setup - run `yarn server db-create` (from the root workspace `fullstack/task`). This will create (and overwrite!) a docker container for the database. +- Otherwise, run the docker container that was created during the first-time setup. + +### 5. Start the server + +Run `yarn server start` from the root workspace. + +## Updating database entities + +The databasee synchronization is turned off by default. Whenever you update an entity and want to propagate that change to the database you need to run a migration. + +First, generate the migration. Make sure to use a descriptive name, like in a commit message. + +``` +yarn server migration -c generate --name migration-name +``` + +This will create a file describing all the changes to the database, s.t. it can be replicated or reverted easily. + +All pending migrations are ran automatically when the server starts. diff --git a/fullstack/task/packages/server/package.json b/fullstack/task/packages/server/package.json index 09d138c0..f6013e9e 100644 --- a/fullstack/task/packages/server/package.json +++ b/fullstack/task/packages/server/package.json @@ -1,77 +1,79 @@ -{ - "name": "server", - "version": "1.0.0", - "description": "", - "author": "", - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "serve": "node dist/main" - }, - "dependencies": { - "@nestjs/apollo": "10.1.3", - "@nestjs/common": "9.3.12", - "@nestjs/config": "2.2.0", - "@nestjs/core": "9.3.12", - "@nestjs/graphql": "10.1.3", - "@nestjs/jwt": "10.0.2", - "@nestjs/passport": "9.0.3", - "@nestjs/platform-express": "9.3.12", - "@nestjs/typeorm": "9.0.1", - "@nestjs/websockets": "9.3.12", - "@sentry/core": "7.51.2", - "@sentry/node": "7.51.2", - "@tuya/tuya-connector-nodejs": "2.1.2", - "apollo-server-core": "3.11.1", - "apollo-server-express": "3.10.2", - "axios": "1.3.4", - "bcrypt": "5.1.0", - "class-transformer": "0.5.1", - "class-validator": "0.13.2", - "crypto-js": "4.1.1", - "dataloader": "2.2.2", - "dotenv": "16.0.3", - "express": "4.18.1", - "fluent-ffmpeg": "^2.1.2", - "graphql": "16.6.0", - "graphql-subscriptions": "2.0.0", - "graphql-ws": "5.12.0", - "mqtt": "4.3.7", - "passport": "0.6.0", - "passport-jwt": "4.0.1", - "pg": "8.8.0", - "reflect-metadata": "0.1.13", - "rimraf": "3.0.2", - "rxjs": "7.2.0", - "typeorm": "0.3.10", - "ws": "8.13.0" - }, - "devDependencies": { - "@dishboard/eslint-config": "1.0.0", - "@dishboard/jest-config": "1.0.0", - "@nestjs/cli": "9.0.0", - "@nestjs/schematics": "9.0.0", - "@nestjs/testing": "9.0.0", - "@rushstack/eslint-patch": "1.2.0", - "@sentry/types": "7.51.2", - "@types/bcrypt": "5.0.0", - "@types/crypto-js": "4.1.1", - "@types/express": "4.17.13", - "@types/fluent-ffmpeg": "^2.1.21", - "@types/jest": "28.1.8", - "@types/passport-jwt": "3.0.8", - "@types/supertest": "2.0.11", - "@types/ws": "8.5.4", - "eslint-import-resolver-typescript": "3.5.3", - "eslint-plugin-import": "2.27.5", - "jest": "28.1.3", - "source-map-support": "0.5.20", - "supertest": "6.1.3", - "ts-jest": "28.0.8" - }, - "peerDependencies": { - "eslint": "*", - "prettier": "*", - "typescript": "*" - } -} +{ + "name": "server", + "version": "1.0.0", + "description": "", + "author": "", + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "serve": "node dist/main" + }, + "dependencies": { + "@nestjs/apollo": "10.1.3", + "@nestjs/common": "9.3.12", + "@nestjs/config": "2.2.0", + "@nestjs/core": "9.3.12", + "@nestjs/graphql": "10.1.3", + "@nestjs/jwt": "10.0.2", + "@nestjs/passport": "9.0.3", + "@nestjs/platform-express": "9.3.12", + "@nestjs/typeorm": "9.0.1", + "@nestjs/websockets": "9.3.12", + "@sentry/core": "7.51.2", + "@sentry/node": "7.51.2", + "@tuya/tuya-connector-nodejs": "2.1.2", + "apollo-server-core": "3.11.1", + "apollo-server-express": "3.10.2", + "axios": "1.3.4", + "bcrypt": "5.1.0", + "class-transformer": "0.5.1", + "class-validator": "0.13.2", + "crypto-js": "4.1.1", + "dataloader": "2.2.2", + "dotenv": "16.0.3", + "express": "4.18.1", + "fluent-ffmpeg": "^2.1.2", + "graphql": "16.6.0", + "graphql-subscriptions": "2.0.0", + "graphql-ws": "5.12.0", + "mqtt": "4.3.7", + "passport": "0.6.0", + "passport-jwt": "4.0.1", + "pg": "8.8.0", + "reflect-metadata": "0.1.13", + "rimraf": "3.0.2", + "rxjs": "7.2.0", + "typeorm": "0.3.10", + "ws": "8.13.0", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "@dishboard/eslint-config": "1.0.0", + "@dishboard/jest-config": "1.0.0", + "@nestjs/cli": "9.0.0", + "@nestjs/schematics": "9.0.0", + "@nestjs/testing": "9.0.0", + "@rushstack/eslint-patch": "1.2.0", + "@sentry/types": "7.51.2", + "@types/bcrypt": "5.0.0", + "@types/crypto-js": "4.1.1", + "@types/express": "4.17.13", + "@types/fluent-ffmpeg": "^2.1.21", + "@types/jest": "28.1.8", + "@types/passport-jwt": "3.0.8", + "@types/supertest": "2.0.11", + "@types/ws": "8.5.4", + "@types/xml2js": "^0.4.14", + "eslint-import-resolver-typescript": "3.5.3", + "eslint-plugin-import": "2.27.5", + "jest": "28.1.3", + "source-map-support": "0.5.20", + "supertest": "6.1.3", + "ts-jest": "28.0.8" + }, + "peerDependencies": { + "eslint": "*", + "prettier": "*", + "typescript": "*" + } +} diff --git a/fullstack/task/packages/server/schema.gql b/fullstack/task/packages/server/schema.gql index 2453a887..23e6dd5f 100644 --- a/fullstack/task/packages/server/schema.gql +++ b/fullstack/task/packages/server/schema.gql @@ -17,8 +17,26 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date """ scalar DateTime +type ExchangeRate { + id: ID! + createdAtUtc: DateTime! + updatedAtUtc: DateTime + deleteDateUtc: DateTime + version: Int! + currency: String! + code: String! + country: String! + amount: Float! + exchangeRate: Float! +} + +type ExchangeRatesResponse { + rates: [ExchangeRate!]! + lastCachedAt: DateTime! +} + type Query { - exchangeRates: String! + exchangeRates: ExchangeRatesResponse! exampleByName(name: String!): Example } diff --git a/fullstack/task/packages/server/src/entities/exchageRate.entity.ts b/fullstack/task/packages/server/src/entities/exchageRate.entity.ts new file mode 100644 index 00000000..61889a2c --- /dev/null +++ b/fullstack/task/packages/server/src/entities/exchageRate.entity.ts @@ -0,0 +1,41 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IsDecimal, IsInt, IsNumber, IsString, MinLength } from 'class-validator'; +import { Column, Entity } from 'typeorm'; +import { EntityWithMeta } from '../common'; +import { VAR_CHAR } from './constants'; + +@ObjectType() +@Entity() +export class ExchangeRate extends EntityWithMeta { + + @IsString() + @MinLength(1) + @Field(() => String) + @Column({ ...VAR_CHAR }) + public currency!: string; + + @IsString() + @MinLength(1) + @Field(() => String) + @Column({ ...VAR_CHAR }) + public code!: string; + + @IsString() + @MinLength(1) + @Field(() => String) + @Column({ ...VAR_CHAR }) + public country!: string; + + @IsInt() + @MinLength(1) + @Field(() => Number) + @Column({ type: 'int' }) + public amount!: number; + + @IsDecimal() + @MinLength(1) + @Field(() => Number) + @Column({ type: 'decimal' }) + public exchangeRate!: number; + +} diff --git a/fullstack/task/packages/server/src/entities/index.ts b/fullstack/task/packages/server/src/entities/index.ts index 6b536a4a..93bcbb5e 100644 --- a/fullstack/task/packages/server/src/entities/index.ts +++ b/fullstack/task/packages/server/src/entities/index.ts @@ -1 +1,2 @@ export * from './example.entity'; +export * from './exchageRate.entity'; diff --git a/fullstack/task/packages/server/src/migrations/1758727973928-create-exchange-rate-table.ts b/fullstack/task/packages/server/src/migrations/1758727973928-create-exchange-rate-table.ts new file mode 100644 index 00000000..75e705f1 --- /dev/null +++ b/fullstack/task/packages/server/src/migrations/1758727973928-create-exchange-rate-table.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class createExchangeRateTable1758727973928 implements MigrationInterface { + name = 'createExchangeRateTable1758727973928' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "exchange_rate" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAtUtc" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAtUtc" TIMESTAMP WITH TIME ZONE DEFAULT now(), "deleteDateUtc" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "currency" character varying(255) NOT NULL, "code" character varying(255) NOT NULL, "country" character varying(255) NOT NULL, "amount" integer NOT NULL, "exchangeRate" numeric NOT NULL, CONSTRAINT "PK_5c5d27d2b900ef6cdeef0398472" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "exchange_rate"`); + } + +} diff --git a/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.module.ts b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.module.ts index d972c6e4..296701f4 100644 --- a/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.module.ts +++ b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateResolver } from './exchange-rate.resolver'; +import { ExchangeRateRepository } from './exchange-rate.repository'; +import { ExchangeRate } from 'src/entities'; +import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ - imports: [], - providers: [ExchangeRateService, ExchangeRateResolver], + imports: [TypeOrmModule.forFeature([ExchangeRate])], + providers: [ExchangeRateService, ExchangeRateResolver, ExchangeRateRepository], exports: [ExchangeRateService], }) export class ExchangeRateModule {} diff --git a/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.repository.ts b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.repository.ts new file mode 100644 index 00000000..991d18e5 --- /dev/null +++ b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.repository.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { ExchangeRate } from '../../entities'; + +@Injectable() +export class ExchangeRateRepository extends Repository {} diff --git a/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.resolver.ts b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.resolver.ts index 39087b66..505c871e 100644 --- a/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.resolver.ts +++ b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.resolver.ts @@ -1,13 +1,21 @@ -import { Query, Resolver } from '@nestjs/graphql'; +import { Field, ObjectType, Query, Resolver } from '@nestjs/graphql'; +import { Example, ExchangeRate } from 'src/entities'; import { ExchangeRateService } from './exchange-rate.service'; +@ObjectType() +export class ExchangeRatesResponse { + @Field(() => [ExchangeRate]) + rates!: ExchangeRate[]; + @Field(() => Date) + lastCachedAt!: Date; +} @Resolver() export class ExchangeRateResolver { constructor(private readonly exchangeRateService: ExchangeRateService) {} // TODO: Implement a GraphQL Query that returns the exchange rates - @Query(() => String) - async exchangeRates(): Promise { - return 'Hello'; + @Query(() => ExchangeRatesResponse) + async exchangeRates(): Promise<{ rates: ExchangeRate[]; lastCachedAt: Date }> { + return this.exchangeRateService.getExchangeRates(); } } diff --git a/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.service.ts b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.service.ts index d9ee80bb..1ff87877 100644 --- a/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.service.ts +++ b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.service.ts @@ -1,11 +1,61 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ExchangeRate } from 'src/entities'; +import { parseXmlToJson } from 'src/utils'; +import { ExchangeRateRepository } from './exchange-rate.repository'; @Injectable() export class ExchangeRateService { - public getExchangeRates = async () => { - // TODO: Implement the fetching and parsing of the exchange rates. - // Use this method in the resolver. + constructor( + @InjectRepository(ExchangeRate) + private readonly exchangeRateRepository: ExchangeRateRepository, + private configService: ConfigService, + ) {} + + private CACHE_LIFETIME_MS = 300000; // 5 minutes in miliseconds + + private async isCacheExpired() { + const [latestRate] = await this.exchangeRateRepository.find({ + order: { + createdAtUtc: 'DESC', + }, + take:1 + }); + if (!latestRate) { + return true; + } + const now = new Date(); + const isCacheExpired = + now.getTime() - latestRate.createdAtUtc.getTime() > this.CACHE_LIFETIME_MS; + return isCacheExpired; + } - return []; + private async cacheRates(): Promise { + const EXCHANGE_RATES_URL = this.configService.get('EXCHANGE_RATES_URL'); + const exchangeRates = await fetch(EXCHANGE_RATES_URL).then((res) => res.text()); + const exchangeRatesJson = await parseXmlToJson<{ + code: string; + currency: string; + country: string; + amount: number; + exchangeRate: number; + }>(exchangeRates); + + await this.exchangeRateRepository.clear(); + await this.exchangeRateRepository.insert(exchangeRatesJson); + } + + public getExchangeRates = async () => { + const isCacheExpired = await this.isCacheExpired(); + if (isCacheExpired) { + await this.cacheRates(); + } + const rates = await this.exchangeRateRepository.find(); + const lastCachedAt = rates[0].createdAtUtc; + return { + rates, + lastCachedAt, + }; }; } diff --git a/fullstack/task/packages/server/src/utils/index.ts b/fullstack/task/packages/server/src/utils/index.ts new file mode 100644 index 00000000..35171cd6 --- /dev/null +++ b/fullstack/task/packages/server/src/utils/index.ts @@ -0,0 +1,59 @@ +import * as xml2js from 'xml2js'; +export function tagNameProcessor(name: string) { + switch (name) { + case 'radek': + return 'rows'; + case 'kurzy': + return 'data'; + case 'tabulka': + return 'table'; + default: + return name; + } +} + +export function attrNameProcessor(name: string) { + switch (name) { + case 'kod': + return 'code'; + case 'mena': + return 'currency'; + case 'mnozstvi': + return 'amount'; + case 'kurz': + return 'exchangeRate'; + case 'zeme': + return 'country'; + default: + return name; + } +} + +export function attrValueProcessor(value: string, name: string) { + switch (name) { + case 'mnozstvi': + return parseInt(value); + case 'kurz': + return parseFloat(value.replace(',', '.')); + default: + return value; + } +} + +export async function parseXmlToJson( + xml: string, + processors = { + tagName: tagNameProcessor, + attrName: attrNameProcessor, + attrValue: attrValueProcessor, + }, +) : Promise>{ + const jsonRes = await xml2js.parseStringPromise(xml, { + tagNameProcessors: [processors.tagName], + attrNameProcessors: [processors.attrName], + attrValueProcessors: [processors.attrValue], + explicitArray: false, + attrkey: 'value', + }); + return jsonRes?.data?.table?.rows.map((row: { value?: T }) => row.value); +} diff --git a/fullstack/task/packages/server/tsconfig.json b/fullstack/task/packages/server/tsconfig.json index a5eee942..ed6b254a 100644 --- a/fullstack/task/packages/server/tsconfig.json +++ b/fullstack/task/packages/server/tsconfig.json @@ -1,9 +1,9 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "noEmit": false, - "outDir": "./dist", - "baseUrl": "./" - }, - "include": ["src"] -} +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "baseUrl": "./" + }, + "include": ["src"] +} diff --git a/fullstack/task/yarn.lock b/fullstack/task/yarn.lock index cb18ed2c..f28f17d4 100644 --- a/fullstack/task/yarn.lock +++ b/fullstack/task/yarn.lock @@ -2404,16 +2404,16 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:18.0.6": - version: 18.0.6 - resolution: "@types/react-dom@npm:18.0.6" +"@types/react-dom@npm:18.2.22": + version: 18.2.22 + resolution: "@types/react-dom@npm:18.2.22" dependencies: "@types/react": "*" - checksum: db571047af1a567631758700b9f7d143e566df939cfe5fbf7535347cc0c726a1cdbb5e3f8566d076e54cf708b6c1166689de194a9ba09ee35efc9e1d45911685 + checksum: 83bb3ddcd7894f20a9a6cbc2befe75d8b76984734020c455b4c50cad625a4025250d8e1d247ef6264e484d47d5a94f2dd11f77c1dcac6a5de998cb447d85b720 languageName: node linkType: hard -"@types/react@npm:*": +"@types/react@npm:*, @types/react@npm:18.2.22": version: 18.2.22 resolution: "@types/react@npm:18.2.22" dependencies: @@ -2424,17 +2424,6 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:18.0.17": - version: 18.0.17 - resolution: "@types/react@npm:18.0.17" - dependencies: - "@types/prop-types": "*" - "@types/scheduler": "*" - csstype: ^3.0.2 - checksum: 18cae64f5bfd6bb58fbd8ee2ba52ec82de844f114254e26de7b513e4b86621f643f9b71d7066958cd571b0d78cb86cbceda449c5289f9349ca573df29ab69252 - languageName: node - linkType: hard - "@types/scheduler@npm:*": version: 0.16.4 resolution: "@types/scheduler@npm:0.16.4" @@ -2509,6 +2498,15 @@ __metadata: languageName: node linkType: hard +"@types/xml2js@npm:^0.4.14": + version: 0.4.14 + resolution: "@types/xml2js@npm:0.4.14" + dependencies: + "@types/node": "*" + checksum: df9f106b9953dcdec7ba3304ebc56d6c2f61d49bf556d600bed439f94a1733f73ca0bf2d0f64330b402191622862d9d6058bab9d7e3dcb5b0fe51ebdc4372aac + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.1 resolution: "@types/yargs-parser@npm:21.0.1" @@ -4076,8 +4074,8 @@ __metadata: "@dishboard/eslint-config": 1.0.0 "@dishboard/jest-config": 1.0.0 "@types/jest": 28.1.8 - "@types/react": 18.0.17 - "@types/react-dom": 18.0.6 + "@types/react": 18.2.22 + "@types/react-dom": 18.2.22 "@types/styled-components": ^5.1.26 "@vitejs/plugin-react": 2.1.0 cross-env: 7.0.3 @@ -9968,6 +9966,7 @@ __metadata: "@types/passport-jwt": 3.0.8 "@types/supertest": 2.0.11 "@types/ws": 8.5.4 + "@types/xml2js": ^0.4.14 apollo-server-core: 3.11.1 apollo-server-express: 3.10.2 axios: 1.3.4 @@ -9997,6 +9996,7 @@ __metadata: ts-jest: 28.0.8 typeorm: 0.3.10 ws: 8.13.0 + xml2js: ^0.6.2 peerDependencies: eslint: "*" prettier: "*" @@ -11653,6 +11653,16 @@ __metadata: languageName: node linkType: hard +"xml2js@npm:^0.6.2": + version: 0.6.2 + resolution: "xml2js@npm:0.6.2" + dependencies: + sax: ">=0.6.0" + xmlbuilder: ~11.0.0 + checksum: 458a83806193008edff44562c0bdb982801d61ee7867ae58fd35fab781e69e17f40dfeb8fc05391a4648c9c54012066d3955fe5d993ffbe4dc63399023f32ac2 + languageName: node + linkType: hard + "xmlbuilder@npm:~11.0.0": version: 11.0.1 resolution: "xmlbuilder@npm:11.0.1"