From a5b012c1f03c431cc41fae8a2d517f3ba253a154 Mon Sep 17 00:00:00 2001 From: Igor Bachev Date: Mon, 30 Dec 2024 18:39:17 +0100 Subject: [PATCH 1/3] Backend configs --- fullstack/task/packages/server/.env | 2 ++ fullstack/task/packages/server/package.json | 1 + fullstack/task/packages/server/schema.gql | 15 ++++++++++++++- fullstack/task/packages/server/src/app.module.ts | 2 ++ .../exchange-rates/exchange-rates-config.ts | 11 +++++++++++ .../server/src/config/exchange-rates/index.ts | 1 + 6 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 fullstack/task/packages/server/src/config/exchange-rates/exchange-rates-config.ts create mode 100644 fullstack/task/packages/server/src/config/exchange-rates/index.ts 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'; From c23b2453ef974c57b6fbbaf30f3f6c7d61b8ea56 Mon Sep 17 00:00:00 2001 From: Igor Bachev Date: Mon, 30 Dec 2024 18:40:30 +0100 Subject: [PATCH 2/3] Backend implementation --- .../packages/server/src/entities/constants.ts | 3 + .../src/entities/exchange-rates.entity.ts | 36 ++++++++ .../1735573865264-createExchangeRatesTable.ts | 14 ++++ .../exchange-rate/exchange-rate.module.ts | 5 +- .../exchange-rate/exchange-rate.repository.ts | 4 + .../exchange-rate/exchange-rate.resolver.ts | 15 ++-- .../exchange-rate/exchange-rate.service.ts | 84 +++++++++++++++++-- 7 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 fullstack/task/packages/server/src/entities/exchange-rates.entity.ts create mode 100644 fullstack/task/packages/server/src/migrations/1735573865264-createExchangeRatesTable.ts create mode 100644 fullstack/task/packages/server/src/services/exchange-rate/exchange-rate.repository.ts 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); }; } From a33878476c31c31b2403f8048d57090383445bea Mon Sep 17 00:00:00 2001 From: Igor Bachev Date: Mon, 30 Dec 2024 18:41:22 +0100 Subject: [PATCH 3/3] Frontend implementation --- fullstack/task/packages/client/package.json | 6 ++ fullstack/task/packages/client/src/App.tsx | 4 +- .../src/components/ExchangeRateTable.tsx | 75 +++++++++++++++++++ .../packages/client/src/graphql/queries.ts | 14 ++++ fullstack/task/packages/client/src/main.tsx | 2 +- 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 fullstack/task/packages/client/src/components/ExchangeRateTable.tsx create mode 100644 fullstack/task/packages/client/src/graphql/queries.ts 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(), });