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(),
});
diff --git a/fullstack/task/packages/server/scripts/db-create.sh b/fullstack/task/packages/server/scripts/db-create.sh
index 54d3b78d..e554304d 100755
--- a/fullstack/task/packages/server/scripts/db-create.sh
+++ b/fullstack/task/packages/server/scripts/db-create.sh
@@ -1,17 +1,31 @@
-echo "Removing old docker container..."
-(docker stop dishboard-dev-task-db && docker kill dishboard-dev-task-db || :) && (docker rm dishboard-dev-task-db || :)
+echo "Removing old docker containers..."
+(docker stop dishboard-dev-task-db pgadmin || :) && (docker rm dishboard-dev-task-db pgadmin || :)
-echo "Creating a new instance..."
+echo "Creating Docker network..."
+docker network create pg-network || echo "Network pg-network already exists."
+
+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
+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
+
echo "Waiting for the database to start..."
-sleep 3
+sleep 5
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;"
+
+echo "PostgreSQL and pgAdmin setup complete. pgAdmin is available at http://localhost:8080/."
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;
};
}