diff --git a/fullstack/task/packages/client/package.json b/fullstack/task/packages/client/package.json
index 819a4b34..07a553fc 100644
--- a/fullstack/task/packages/client/package.json
+++ b/fullstack/task/packages/client/package.json
@@ -4,6 +4,12 @@
"version": "1.0.0",
"dependencies": {
"@apollo/client": "3.7.10",
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.0",
+ "@mui/material": "^6.3.0",
+ "@mui/system": "^6.3.0",
+ "@mui/x-data-grid": "^7.23.5",
+ "dayjs": "^1.11.13",
"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..c6fce728 100644
--- a/fullstack/task/packages/client/src/App.tsx
+++ b/fullstack/task/packages/client/src/App.tsx
@@ -1,5 +1,7 @@
+import ExchangeRateTable from "./components/ExchangeRateTable";
+
function App() {
- return
TODO
;
+ return ;
}
export default App;
diff --git a/fullstack/task/packages/client/src/components/ExchangeRateTable.tsx b/fullstack/task/packages/client/src/components/ExchangeRateTable.tsx
new file mode 100644
index 00000000..1f4592f1
--- /dev/null
+++ b/fullstack/task/packages/client/src/components/ExchangeRateTable.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { useQuery } from '@apollo/client';
+import { GET_EXCHANGE_RATES } from '../graphql/queries';
+import { DataGrid, GridToolbar } from '@mui/x-data-grid';
+import { Typography, Box, Paper } from '@mui/material';
+import dayjs from 'dayjs';
+
+const ExchangeRateTable: React.FC = () => {
+ const { loading, error, data } = useQuery(GET_EXCHANGE_RATES);
+ console.log('data', data);
+
+ if (loading) return Loading...
;
+ if (error) return Error: {error.message}
;
+
+ const rows = data.exchangeRates.map((rate: any, index: number) => ({
+ id: index,
+ amount: rate.amount || 'N/A',
+ code: rate.code || 'N/A',
+ country: rate.country || 'N/A',
+ currency: rate.currency,
+ rate: rate.rate,
+ }));
+
+ const columns = [
+ { field: 'country', headerName: 'Country', width: 150 },
+ { field: 'currency', headerName: 'Currency', width: 150 },
+ { field: 'amount', headerName: 'Amount', width: 150 },
+ { field: 'code', headerName: 'Code', width: 150 },
+ { field: 'rate', headerName: 'Rate', width: 100 },
+ ];
+
+ const lastFetched = dayjs(data.exchangeRates[0]?.updatedAtUtc).format(
+ 'MMMM D, YYYY h:mm A'
+ );
+
+ return (
+
+
+
+
+ Last Fetched: {lastFetched}
+
+
+
+
+
+ );
+};
+
+export default ExchangeRateTable;
diff --git a/fullstack/task/packages/client/src/graphql/queries.ts b/fullstack/task/packages/client/src/graphql/queries.ts
new file mode 100644
index 00000000..09182a57
--- /dev/null
+++ b/fullstack/task/packages/client/src/graphql/queries.ts
@@ -0,0 +1,14 @@
+import { gql } from '@apollo/client';
+
+export const GET_EXCHANGE_RATES = gql`
+ query {
+ exchangeRates {
+ rate
+ currency
+ country
+ amount
+ code
+ updatedAtUtc
+ }
+ }
+`;
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/.env b/fullstack/task/packages/server/.env
index 32fba8c8..36d6ea0f 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_RATE_API_URL=https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt
\ No newline at end of file
diff --git a/fullstack/task/packages/server/package.json b/fullstack/task/packages/server/package.json
index 09d138c0..76d6d8bf 100644
--- a/fullstack/task/packages/server/package.json
+++ b/fullstack/task/packages/server/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@nestjs/apollo": "10.1.3",
+ "@nestjs/axios": "^3.1.3",
"@nestjs/common": "9.3.12",
"@nestjs/config": "2.2.0",
"@nestjs/core": "9.3.12",
diff --git a/fullstack/task/packages/server/schema.gql b/fullstack/task/packages/server/schema.gql
index 2453a887..8b6f8a86 100644
--- a/fullstack/task/packages/server/schema.gql
+++ b/fullstack/task/packages/server/schema.gql
@@ -17,8 +17,21 @@ 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!
+ country: String!
+ currency: String!
+ amount: Float!
+ code: String!
+ rate: Float!
+}
+
type Query {
- exchangeRates: String!
+ exchangeRates: [ExchangeRate!]!
exampleByName(name: String!): Example
}
diff --git a/fullstack/task/packages/server/src/app.module.ts b/fullstack/task/packages/server/src/app.module.ts
index 386fe012..f9860e99 100644
--- a/fullstack/task/packages/server/src/app.module.ts
+++ b/fullstack/task/packages/server/src/app.module.ts
@@ -5,11 +5,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { graphqlConfig, typeormConfig } from './config';
import { modules } from './entity-modules';
import { ExchangeRateModule } from './services/exchange-rate/exchange-rate.module';
+import { exchangeRateConfig } from './config/exchange-rates';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
+ load: [() => ({ exchangeRate: exchangeRateConfig })],
}),
TypeOrmModule.forRoot(typeormConfig),
GraphQLModule.forRoot(graphqlConfig),
diff --git a/fullstack/task/packages/server/src/config/exchange-rates/exchange-rates-config.ts b/fullstack/task/packages/server/src/config/exchange-rates/exchange-rates-config.ts
new file mode 100644
index 00000000..ec9487a3
--- /dev/null
+++ b/fullstack/task/packages/server/src/config/exchange-rates/exchange-rates-config.ts
@@ -0,0 +1,11 @@
+import { config } from 'dotenv';
+
+config();
+
+const {
+ EXCHANGE_RATE_API_URL: exchangeRateApi,
+} = process.env;
+
+export const exchangeRateConfig = {
+ apiUrl: exchangeRateApi,
+};
diff --git a/fullstack/task/packages/server/src/config/exchange-rates/index.ts b/fullstack/task/packages/server/src/config/exchange-rates/index.ts
new file mode 100644
index 00000000..5d4450d6
--- /dev/null
+++ b/fullstack/task/packages/server/src/config/exchange-rates/index.ts
@@ -0,0 +1 @@
+export * from './exchange-rates-config';
diff --git a/fullstack/task/packages/server/src/entities/constants.ts b/fullstack/task/packages/server/src/entities/constants.ts
index 377c252f..0c200ded 100644
--- a/fullstack/task/packages/server/src/entities/constants.ts
+++ b/fullstack/task/packages/server/src/entities/constants.ts
@@ -1 +1,4 @@
export const VAR_CHAR = { type: 'varchar', length: 255 } as const;
+export const DECIMAL = { type: 'decimal' } as const;
+export const TIMESTAMP_TZ = { type: 'timestamptz' } as const;
+export const INT = { type: 'int' } as const;
\ No newline at end of file
diff --git a/fullstack/task/packages/server/src/entities/exchange-rates.entity.ts b/fullstack/task/packages/server/src/entities/exchange-rates.entity.ts
new file mode 100644
index 00000000..a0817274
--- /dev/null
+++ b/fullstack/task/packages/server/src/entities/exchange-rates.entity.ts
@@ -0,0 +1,36 @@
+import { Field, Float, ObjectType } from '@nestjs/graphql';
+import { IsInt, IsString, MinLength } from 'class-validator';
+import { Column, Entity } from 'typeorm';
+import { EntityWithMeta } from '../common';
+import { VAR_CHAR, DECIMAL, INT } from './constants';
+
+@ObjectType()
+@Entity()
+export class ExchangeRate extends EntityWithMeta {
+ @IsString()
+ @MinLength(1)
+ @Field(() => String)
+ @Column({ ...VAR_CHAR })
+ public country!: string;
+
+ @IsString()
+ @MinLength(1)
+ @Field(() => String)
+ @Column({ ...VAR_CHAR })
+ public currency!: string;
+
+ @IsInt()
+ @Field(() => Number)
+ @Column({ ...INT })
+ public amount!: number;
+
+ @IsString()
+ @MinLength(1)
+ @Field(() => String)
+ @Column({ ...VAR_CHAR })
+ public code!: string;
+
+ @Field(() => Float)
+ @Column({ ...DECIMAL })
+ public rate!: number;
+}
diff --git a/fullstack/task/packages/server/src/migrations/1735573865264-createExchangeRatesTable.ts b/fullstack/task/packages/server/src/migrations/1735573865264-createExchangeRatesTable.ts
new file mode 100644
index 00000000..c29dadcd
--- /dev/null
+++ b/fullstack/task/packages/server/src/migrations/1735573865264-createExchangeRatesTable.ts
@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class createExchangeRatesTable1735573865264 implements MigrationInterface {
+ name = 'createExchangeRatesTable1735573865264'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "exchange_rate" ALTER COLUMN "rate" TYPE numeric`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "exchange_rate" ALTER COLUMN "rate" TYPE numeric(3,3)`);
+ }
+
+}
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..8a563ddc 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,12 @@
import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeRateService } from './exchange-rate.service';
import { ExchangeRateResolver } from './exchange-rate.resolver';
+import { HttpModule } from '@nestjs/axios';
+import { ExchangeRate } from 'src/entities/exchange-rates.entity';
@Module({
- imports: [],
+ imports: [HttpModule,TypeOrmModule.forFeature([ExchangeRate])],
providers: [ExchangeRateService, ExchangeRateResolver],
exports: [ExchangeRateService],
})
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..bb6ccc44
--- /dev/null
+++ b/fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.repository.ts
@@ -0,0 +1,4 @@
+import { Repository } from 'typeorm';
+import { ExchangeRate } from 'src/entities/exchange-rates.entity';
+
+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..c9bfc330 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 { ExchangeRateService } from './exchange-rate.service';
+import { ExchangeRate } from 'src/entities/exchange-rates.entity';
-@Resolver()
+@Resolver(() => ExchangeRate)
export class ExchangeRateResolver {
- constructor(private readonly exchangeRateService: ExchangeRateService) {}
+ 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 exchangeRates = await this.exchangeRateService.getExchangeRates();
+ return exchangeRates;
+ }
}
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..2305b4bb 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,85 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { HttpService } from '@nestjs/axios';
+import { firstValueFrom } from 'rxjs';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { ExchangeRate } from 'src/entities/exchange-rates.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 readonly CACHE_LIFETIME_MS = 5 * 60 * 1000; // 5 minutes
- return [];
+ constructor(
+ private readonly configService: ConfigService,
+ private readonly httpService: HttpService,
+
+ @InjectRepository(ExchangeRate)
+ private readonly exchangeRateRepository: Repository
+ ) {}
+
+ public getExchangeRates = async (): Promise => {
+ const now = new Date();
+ const oldestAllowedTime = new Date(now.getTime() - this.CACHE_LIFETIME_MS);
+
+ // Check if cached data is still valid
+ const latestRate = await this.exchangeRateRepository.findOne({
+ where: {},
+ order: { updatedAtUtc: 'DESC' },
+ });
+
+ if (latestRate && latestRate.updatedAtUtc && latestRate.updatedAtUtc > oldestAllowedTime) {
+ console.log('Returning cached exchange rates from the database');
+ return await this.getExchangeRatesFromDB();
+ }
+
+ // Fetch new data from the API
+ console.log('Fetching new exchange rates from the API');
+ const apiUrl = this.configService.get('exchangeRate.apiUrl') || '';
+
+ try {
+ const response = await firstValueFrom(this.httpService.get(apiUrl));
+ console.log('API Response:', response.data);
+
+ const rates = this.parseExchangeRates(response.data);
+ await this.saveExchangeRatesToDB(rates);
+
+ console.log('Exchange rates updated successfully');
+ return rates;
+ } catch (err) {
+ console.error('Error fetching exchange rates:', err);
+ throw new Error('Failed to fetch exchange rates from the API');
+ }
+ };
+
+ private getExchangeRatesFromDB = async (): Promise => {
+ const rates = await this.exchangeRateRepository.find();
+ if (!rates || rates.length === 0) {
+ throw new NotFoundException('No exchange rates found in the database');
+ }
+ return rates;
+ };
+
+ private parseExchangeRates = (data: string): ExchangeRate[] => {
+ return data
+ .split('\n')
+ .slice(2)
+ .filter(line => line.trim())
+ .map(line => {
+ const [country, currency, amount, code, rate] = line.split('|');
+ return {
+ country: country.trim(),
+ currency: currency.trim(),
+ amount: parseInt(amount, 10),
+ code: code.trim(),
+ rate: parseFloat(rate),
+ } as ExchangeRate;
+ })
+ };
+
+ private saveExchangeRatesToDB = async (rates: ExchangeRate[]): Promise => {
+ await this.exchangeRateRepository.clear();
+
+ await this.exchangeRateRepository.save(rates);
};
}