From 3c4d26304893f77e2ca0223f32ecd51ec438ccbd Mon Sep 17 00:00:00 2001 From: Pashazade Date: Mon, 30 Dec 2024 01:40:14 +0200 Subject: [PATCH 1/4] Feat: add scripts to run pgadmin container after DB creation --- .../task/packages/server/scripts/db-create.sh | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/fullstack/task/packages/server/scripts/db-create.sh b/fullstack/task/packages/server/scripts/db-create.sh index 54d3b78d..6ff41bf4 100755 --- a/fullstack/task/packages/server/scripts/db-create.sh +++ b/fullstack/task/packages/server/scripts/db-create.sh @@ -1,17 +1,40 @@ -echo "Removing old docker container..." -(docker stop dishboard-dev-task-db && docker kill dishboard-dev-task-db || :) && (docker rm dishboard-dev-task-db || :) +#!/bin/bash -echo "Creating a new instance..." +# 1. Remove old containers if they exist +echo "Removing old docker containers..." +(docker stop dishboard-dev-task-db pgadmin || :) && (docker rm dishboard-dev-task-db pgadmin || :) + +# 2. Create Docker network if it doesn't exist +echo "Creating Docker network..." +docker network create pg-network || echo "Network pg-network already exists." + +# 3. Start PostgreSQL container +echo "Starting PostgreSQL container..." docker run \ --name dishboard-dev-task-db \ -e POSTGRES_PASSWORD=postgres \ -e PGPASSWORD=postgres \ -p 5430:5432 \ + --network pg-network \ -d postgres +# 4. Start pgAdmin container +echo "Starting pgAdmin container..." +docker run \ + --name pgadmin \ + -e PGADMIN_DEFAULT_EMAIL=pgadminuser@gmail.com \ + -e PGADMIN_DEFAULT_PASSWORD=Database123! \ + -p 8080:80 \ + --network pg-network \ + -d dpage/pgadmin4:latest + +# 5. Wait for the database to start echo "Waiting for the database to start..." -sleep 3 +sleep 5 +# 6. Create the database echo "Creating the database..." -echo "CREATE DATABASE dev;" | docker exec -i dishboard-dev-task-db psql -U postgres -echo "\l" | docker exec -i dishboard-dev-task-db psql -U postgres +docker exec -i dishboard-dev-task-db psql -U postgres -c "CREATE DATABASE dev;" + +# 7. Provide instructions +echo "PostgreSQL and pgAdmin setup complete. pgAdmin is available at http://localhost:8080/." From b41efb8bdd1616d14cd0e0b88d51ad60992fb8a2 Mon Sep 17 00:00:00 2001 From: Pashazade Date: Mon, 30 Dec 2024 01:44:28 +0200 Subject: [PATCH 2/4] Fix: remove uncessary codes --- fullstack/task/packages/server/scripts/db-create.sh | 9 --------- 1 file changed, 9 deletions(-) diff --git a/fullstack/task/packages/server/scripts/db-create.sh b/fullstack/task/packages/server/scripts/db-create.sh index 6ff41bf4..e554304d 100755 --- a/fullstack/task/packages/server/scripts/db-create.sh +++ b/fullstack/task/packages/server/scripts/db-create.sh @@ -1,14 +1,9 @@ -#!/bin/bash - -# 1. Remove old containers if they exist echo "Removing old docker containers..." (docker stop dishboard-dev-task-db pgadmin || :) && (docker rm dishboard-dev-task-db pgadmin || :) -# 2. Create Docker network if it doesn't exist echo "Creating Docker network..." docker network create pg-network || echo "Network pg-network already exists." -# 3. Start PostgreSQL container echo "Starting PostgreSQL container..." docker run \ --name dishboard-dev-task-db \ @@ -18,7 +13,6 @@ docker run \ --network pg-network \ -d postgres -# 4. Start pgAdmin container echo "Starting pgAdmin container..." docker run \ --name pgadmin \ @@ -28,13 +22,10 @@ docker run \ --network pg-network \ -d dpage/pgadmin4:latest -# 5. Wait for the database to start echo "Waiting for the database to start..." sleep 5 -# 6. Create the database echo "Creating the database..." docker exec -i dishboard-dev-task-db psql -U postgres -c "CREATE DATABASE dev;" -# 7. Provide instructions echo "PostgreSQL and pgAdmin setup complete. pgAdmin is available at http://localhost:8080/." From ec5dc875d6dda1b9b846e6e62dd9efde06ad39fd Mon Sep 17 00:00:00 2001 From: Pashazade Date: Mon, 30 Dec 2024 17:21:03 +0200 Subject: [PATCH 3/4] Feat: Backend implementation --- .../src/entities/exchange-rate.entity.ts | 35 +++++++++++ ...35559537285-create-exchnage-rates-table.ts | 14 +++++ .../exchange-rate/exchange-rate.module.ts | 6 +- .../exchange-rate/exchange-rate.resolver.ts | 11 ++-- .../exchange-rate/exchange-rate.service.ts | 60 +++++++++++++++++-- 5 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 fullstack/task/packages/server/src/entities/exchange-rate.entity.ts create mode 100644 fullstack/task/packages/server/src/migrations/1735559537285-create-exchnage-rates-table.ts diff --git a/fullstack/task/packages/server/src/entities/exchange-rate.entity.ts b/fullstack/task/packages/server/src/entities/exchange-rate.entity.ts new file mode 100644 index 00000000..17e34c69 --- /dev/null +++ b/fullstack/task/packages/server/src/entities/exchange-rate.entity.ts @@ -0,0 +1,35 @@ +import { Field, Float, Int, ObjectType } from '@nestjs/graphql'; +import { Column, Entity } from 'typeorm'; +import { EntityWithMeta } from '../common'; + +@ObjectType() +@Entity({ name: 'exchange_rates' }) +export class ExchangeRate extends EntityWithMeta { + @Column() + @Field() + validFor!: string; + + @Column() + @Field(() => Int) + order!: number; + + @Column() + @Field() + country!: string; + + @Column() + @Field() + currency!: string; + + @Column() + @Field(() => Float) + amount!: number; + + @Column() + @Field() + currencyCode!: string; + + @Column('decimal') + @Field(() => Float) + rate!: number; +} diff --git a/fullstack/task/packages/server/src/migrations/1735559537285-create-exchnage-rates-table.ts b/fullstack/task/packages/server/src/migrations/1735559537285-create-exchnage-rates-table.ts new file mode 100644 index 00000000..11f87178 --- /dev/null +++ b/fullstack/task/packages/server/src/migrations/1735559537285-create-exchnage-rates-table.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class createExchnageRatesTable1735559537285 implements MigrationInterface { + name = 'createExchnageRatesTable1735559537285' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "exchange_rates" ("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, "validFor" character varying NOT NULL, "order" integer NOT NULL, "country" character varying NOT NULL, "currency" character varying NOT NULL, "amount" integer NOT NULL, "currencyCode" character varying NOT NULL, "rate" numeric NOT NULL, CONSTRAINT "PK_33a614bad9e61956079d817ebe2" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "exchange_rates"`); + } + +} 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..02c04202 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,9 +1,11 @@ import { Module } from '@nestjs/common'; -import { ExchangeRateService } from './exchange-rate.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeRate } from '../../entities/exchange-rate.entity'; import { ExchangeRateResolver } from './exchange-rate.resolver'; +import { ExchangeRateService } from './exchange-rate.service'; @Module({ - imports: [], + imports: [TypeOrmModule.forFeature([ExchangeRate])], providers: [ExchangeRateService, ExchangeRateResolver], exports: [ExchangeRateService], }) 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..a471ec0f 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,14 @@ import { Query, Resolver } from '@nestjs/graphql'; +import { ExchangeRate } from '../../entities/exchange-rate.entity'; import { ExchangeRateService } from './exchange-rate.service'; -@Resolver() +@Resolver(() => ExchangeRate) 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(() => [ExchangeRate]) + async exchangeRates(): Promise { + const rates = await this.exchangeRateService.getExchangeRates(); + return rates || []; } } 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..0623469e 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,63 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import axios from 'axios'; +import { MoreThanOrEqual, Repository } from 'typeorm'; +import { ExchangeRate } from '../../entities/exchange-rate.entity'; @Injectable() export class ExchangeRateService { - public getExchangeRates = async () => { - // TODO: Implement the fetching and parsing of the exchange rates. - // Use this method in the resolver. + private cacheLifetimeInMinutes = 5; + private exchangeRatesApiUrl = `https://api.cnb.cz/cnbapi/exrates`; - return []; + constructor( + @InjectRepository(ExchangeRate) + private readonly exchangeRateRepository: Repository + ) {} + + public getExchangeRates = async (): Promise => { + const cachedRates = await this.getCachedRates(); + + if (cachedRates?.length) { + return cachedRates; + } + + const response = await axios.get(`${this.exchangeRatesApiUrl}/daily`); + + const exchangeRates: ExchangeRate[] = response.data.rates.map((rate: any) => ({ + validFor: rate.validFor, + order: rate.order, + country: rate.country, + currency: rate.currency, + amount: rate.amount, + currencyCode: rate.currencyCode, + rate: rate.rate, + })); + + await this.saveExchangeRates(exchangeRates); + + const rates = await this.exchangeRateRepository.find(); + + return rates; + }; + + private saveExchangeRates = async (exchangeRates: ExchangeRate[]): Promise => { + await this.exchangeRateRepository.delete({}); + await this.exchangeRateRepository.save(exchangeRates); + }; + + private getCachedRates = async (): Promise => { + const cacheExpirationTime = new Date(); + + cacheExpirationTime.setMinutes( + cacheExpirationTime.getMinutes() - this.cacheLifetimeInMinutes + ); + + const cachedRates = await this.exchangeRateRepository.find({ + where: { + createdAtUtc: MoreThanOrEqual(cacheExpirationTime), + }, + }); + + return cachedRates || null; }; } From 5a127b507845c7394cabcc4de1006439e7468fe6 Mon Sep 17 00:00:00 2001 From: Pashazade Date: Mon, 30 Dec 2024 17:45:33 +0200 Subject: [PATCH 4/4] Feat: FrontEnd implementation --- fullstack/task/packages/client/package.json | 4 + fullstack/task/packages/client/src/App.tsx | 12 ++- .../packages/client/src/ExchangeRates.tsx | 78 +++++++++++++++++++ .../client/src/components/loader/Loader.tsx | 17 ++++ .../src/graphql/queries/exchange-rates.ts | 26 +++++++ fullstack/task/packages/client/src/main.tsx | 2 +- 6 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 fullstack/task/packages/client/src/ExchangeRates.tsx create mode 100644 fullstack/task/packages/client/src/components/loader/Loader.tsx create mode 100644 fullstack/task/packages/client/src/graphql/queries/exchange-rates.ts diff --git a/fullstack/task/packages/client/package.json b/fullstack/task/packages/client/package.json index 819a4b34..2421fc7f 100644 --- a/fullstack/task/packages/client/package.json +++ b/fullstack/task/packages/client/package.json @@ -4,6 +4,10 @@ "version": "1.0.0", "dependencies": { "@apollo/client": "3.7.10", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^6.3.0", + "@mui/material": "^6.3.0", "graphql": "16.6.0", "graphql-tag": "2.12.6", "graphql-ws": "5.12.0", diff --git a/fullstack/task/packages/client/src/App.tsx b/fullstack/task/packages/client/src/App.tsx index f62d2a02..1553f2db 100644 --- a/fullstack/task/packages/client/src/App.tsx +++ b/fullstack/task/packages/client/src/App.tsx @@ -1,5 +1,11 @@ -function App() { - return

TODO

; -} +import { ExchangeRates } from './ExchangeRates'; + +const App = () => { + return ( +
+ +
+ ); +}; export default App; diff --git a/fullstack/task/packages/client/src/ExchangeRates.tsx b/fullstack/task/packages/client/src/ExchangeRates.tsx new file mode 100644 index 00000000..d68ad625 --- /dev/null +++ b/fullstack/task/packages/client/src/ExchangeRates.tsx @@ -0,0 +1,78 @@ +import { useQuery } from '@apollo/client'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography +} from '@mui/material'; +import { useEffect, useState } from 'react'; +import { Loader } from './components/loader/Loader'; +import { ExchangeRate, GET_EXCHANGE_RATES } from './graphql/queries/exchange-rates'; + +export const ExchangeRates = () => { + const { data, loading, error } = useQuery(GET_EXCHANGE_RATES); + const [lastFetched, setLastFetched] = useState(null); + const [elapsedTime, setElapsedTime] = useState(`Last updated just now`); + + useEffect(() => { + if (data) { + setLastFetched(new Date()); + } + }, [data]); + + useEffect(() => { + const interval = setInterval(() => { + if (lastFetched) { + const timeDiff = new Date().getTime() - lastFetched.getTime(); + const minutesAgo = Math.floor(timeDiff / 1000 / 60); + setElapsedTime(`Last fetched ${minutesAgo} minute(s) ago`); + } + }, 60000); + + return () => clearInterval(interval); + }, [lastFetched]); + + if (loading) return ; + + if (error) return Error: {error.message}; + + return ( +
+ + Exchange Rates + + + {elapsedTime} + + + + + + Country + Currency + Amount + Currency Code + Rate + + + + {data.exchangeRates.map((rate: ExchangeRate) => ( + + {rate.country} + {rate.currency} + {rate.amount} + {rate.currencyCode} + {rate.rate} + + ))} + +
+
+
+ ); +}; 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..2f464ff9 --- /dev/null +++ b/fullstack/task/packages/client/src/components/loader/Loader.tsx @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { CircularProgress } from '@mui/material'; + +export const Loader = () => { + return ( +
+ +
+ ); +}; diff --git a/fullstack/task/packages/client/src/graphql/queries/exchange-rates.ts b/fullstack/task/packages/client/src/graphql/queries/exchange-rates.ts new file mode 100644 index 00000000..ecadb1f1 --- /dev/null +++ b/fullstack/task/packages/client/src/graphql/queries/exchange-rates.ts @@ -0,0 +1,26 @@ +import { gql } from '@apollo/client'; + +export interface ExchangeRate { + validFor: string; + order: number; + country: string; + currency: string; + amount: number; + currencyCode: string; + rate: number; +} + +export const GET_EXCHANGE_RATES = gql` + query GetExchangeRates { + exchangeRates { + id + validFor + order + country + currency + amount + currencyCode + rate + } + } +`; diff --git a/fullstack/task/packages/client/src/main.tsx b/fullstack/task/packages/client/src/main.tsx index ef5f39c2..2ce0b3e1 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(), });