diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..496ee2ca --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/fullstack/task/.yarn/cache/esbuild-darwin-64-npm-0.15.18-c3c12de20e-8.zip b/fullstack/task/.yarn/cache/esbuild-darwin-64-npm-0.15.18-c3c12de20e-8.zip deleted file mode 100644 index be89e66c..00000000 Binary files a/fullstack/task/.yarn/cache/esbuild-darwin-64-npm-0.15.18-c3c12de20e-8.zip and /dev/null differ diff --git a/fullstack/task/.yarn/cache/esbuild-darwin-arm64-npm-0.15.18-37bdbfa731-8.zip b/fullstack/task/.yarn/cache/esbuild-darwin-arm64-npm-0.15.18-37bdbfa731-8.zip new file mode 100644 index 00000000..5fa96fd3 Binary files /dev/null and b/fullstack/task/.yarn/cache/esbuild-darwin-arm64-npm-0.15.18-37bdbfa731-8.zip differ diff --git a/fullstack/task/.yarn/cache/esbuild-linux-64-npm-0.15.18-b7675c5a72-8.zip b/fullstack/task/.yarn/cache/esbuild-linux-64-npm-0.15.18-b7675c5a72-8.zip deleted file mode 100644 index 86ff108e..00000000 Binary files a/fullstack/task/.yarn/cache/esbuild-linux-64-npm-0.15.18-b7675c5a72-8.zip and /dev/null differ diff --git a/fullstack/task/.yarn/cache/esbuild-linux-arm64-npm-0.15.18-16f8e6f421-8.zip b/fullstack/task/.yarn/cache/esbuild-linux-arm64-npm-0.15.18-16f8e6f421-8.zip new file mode 100644 index 00000000..906abfa1 Binary files /dev/null and b/fullstack/task/.yarn/cache/esbuild-linux-arm64-npm-0.15.18-16f8e6f421-8.zip differ diff --git a/fullstack/task/.yarn/cache/esbuild-windows-64-npm-0.15.18-f926268f42-8.zip b/fullstack/task/.yarn/cache/esbuild-windows-64-npm-0.15.18-f926268f42-8.zip deleted file mode 100644 index 5748da7e..00000000 Binary files a/fullstack/task/.yarn/cache/esbuild-windows-64-npm-0.15.18-f926268f42-8.zip and /dev/null differ diff --git a/fullstack/task/.yarn/cache/esbuild-windows-arm64-npm-0.15.18-ca93639f32-8.zip b/fullstack/task/.yarn/cache/esbuild-windows-arm64-npm-0.15.18-ca93639f32-8.zip new file mode 100644 index 00000000..42a38754 Binary files /dev/null and b/fullstack/task/.yarn/cache/esbuild-windows-arm64-npm-0.15.18-ca93639f32-8.zip differ diff --git a/fullstack/task/packages/client/project.json b/fullstack/task/packages/client/project.json index e1bd7894..265a5ca4 100644 --- a/fullstack/task/packages/client/project.json +++ b/fullstack/task/packages/client/project.json @@ -9,7 +9,7 @@ "start": { "executor": "nx:run-commands", "options": { - "command": "yarn vite", + "command": "yarn vite --host 0.0.0.0", "cwd": "packages/client" } }, diff --git a/fullstack/task/packages/client/src/App.tsx b/fullstack/task/packages/client/src/App.tsx index f62d2a02..00c1726f 100644 --- a/fullstack/task/packages/client/src/App.tsx +++ b/fullstack/task/packages/client/src/App.tsx @@ -1,5 +1,49 @@ +import React from 'react'; +import { useQuery } from '@apollo/client'; +import { GET_EXCHANGE_RATES } from './graphql/queries'; +import { ExchangeRate } from './types/ExchangeRate'; + +interface ExchangeRateResponse { + exchangeRatesList: ExchangeRate[]; +} + function App() { - return

TODO

; + const { data, loading, error } = useQuery(GET_EXCHANGE_RATES); + + if (loading) return
Loading...
; + if (error) return
Error: {error.message}
; + + const exchangeRates = data?.exchangeRatesList || []; + + return ( +
+

Exchange Rates

+ + + + + + + + + + + + + {exchangeRates.map((rate) => ( + + + + + + + + + ))} + +
CountryCurrencyAmountCodeRateLast updated at
{rate.country}{rate.currency}{rate.amount}{rate.currencyCode}{rate.rate}{rate.updatedAtUtc}
+
+ ); } export default App; 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..e5f50724 --- /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 GetExchangeRates { + exchangeRatesList { + country + currency + currencyCode + amount + rate + updatedAtUtc + } + } +`; diff --git a/fullstack/task/packages/client/src/main.tsx b/fullstack/task/packages/client/src/main.tsx index ef5f39c2..070c8ee1 100644 --- a/fullstack/task/packages/client/src/main.tsx +++ b/fullstack/task/packages/client/src/main.tsx @@ -4,8 +4,10 @@ import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; import App from './App'; const client = new ApolloClient({ - uri: 'http://localhost:4000/graphql', - cache: new InMemoryCache(), + uri: 'http://localhost:4001/graphql', + cache: new InMemoryCache({ + addTypename: false + }), }); ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/fullstack/task/packages/client/src/types/ExchangeRate.ts b/fullstack/task/packages/client/src/types/ExchangeRate.ts new file mode 100644 index 00000000..21c1ed28 --- /dev/null +++ b/fullstack/task/packages/client/src/types/ExchangeRate.ts @@ -0,0 +1,8 @@ +export interface ExchangeRate { + country: string; + currency: string; + amount: number; + currencyCode: string; + rate: number; + updatedAtUtc?: string; +} diff --git a/fullstack/task/packages/server/project.json b/fullstack/task/packages/server/project.json index 5dbf7487..253f6348 100644 --- a/fullstack/task/packages/server/project.json +++ b/fullstack/task/packages/server/project.json @@ -42,16 +42,16 @@ }, "configurations": { "run": { - "command": "yarn typeorm-ts-node-commonjs migration:run -d ./src/config/typeorm/data-source.ts" + "command": "yarn typeorm-ts-node-commonjs migration:run --dataSource ./src/config/typeorm/data-source.ts" }, "revert": { - "command": "yarn typeorm-ts-node-commonjs migration:revert -d ./src/config/typeorm/data-source.ts" + "command": "yarn typeorm-ts-node-commonjs migration:revert --dataSource ./src/config/typeorm/data-source.ts" }, "generate": { - "command": "yarn typeorm-ts-node-commonjs migration:generate -d ./src/config/typeorm/data-source.ts ./src/migrations/{args.name}" + "command": "yarn typeorm-ts-node-commonjs migration:generate --dataSource ./src/config/typeorm/data-source.ts ./src/migrations/{args.name}" }, "create": { - "command": "yarn typeorm-ts-node-commonjs migration:create -d ./src/config/typeorm/data-source.ts ./src/migrations/{args.name}" + "command": "yarn typeorm-ts-node-commonjs migration:create ./src/migrations/{args.name}" } } } diff --git a/fullstack/task/packages/server/schema.gql b/fullstack/task/packages/server/schema.gql index 2453a887..8d898d21 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! + currencyCode: String! + rate: Float! +} + type Query { - exchangeRates: String! + exchangeRatesList: [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..f76f5506 100644 --- a/fullstack/task/packages/server/src/app.module.ts +++ b/fullstack/task/packages/server/src/app.module.ts @@ -5,6 +5,7 @@ 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 { CurrencyFetchModule } from './services/currency-fetch/currency-fetch.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { ExchangeRateModule } from './services/exchange-rate/exchange-rate.modul TypeOrmModule.forRoot(typeormConfig), GraphQLModule.forRoot(graphqlConfig), ExchangeRateModule, + CurrencyFetchModule, ...modules, ], controllers: [], diff --git a/fullstack/task/packages/server/src/entities/cache-metadata.entity.ts b/fullstack/task/packages/server/src/entities/cache-metadata.entity.ts new file mode 100644 index 00000000..6a7cd2fc --- /dev/null +++ b/fullstack/task/packages/server/src/entities/cache-metadata.entity.ts @@ -0,0 +1,16 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IsString } from 'class-validator'; +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@ObjectType() +@Entity('cache_metadata') +export class CacheMetadata { + @IsString() + @Field(() => String) + @PrimaryColumn({ type: 'varchar', length: 50 }) + public cacheKey!: string; + + @Field(() => Date) + @Column({ type: 'timestamptz' }) + public lastFetchedAt!: Date; +} 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..c9e3842b --- /dev/null +++ b/fullstack/task/packages/server/src/entities/exchange-rate.entity.ts @@ -0,0 +1,36 @@ +import { Field, Float, ObjectType } from '@nestjs/graphql'; +import { IsDecimal, IsString, Length } from 'class-validator'; +import { Column, Entity, Index } from 'typeorm'; +import { EntityWithMeta } from '../common'; +import { VAR_CHAR } from './constants'; + +@ObjectType() +@Entity('exchange_rates') +@Index('IDX_exchange_rates_currency_code', ['currencyCode'], { unique: true }) +export class ExchangeRate extends EntityWithMeta { + @IsString() + @Field(() => String) + @Column({ ...VAR_CHAR }) + public country!: string; + + @IsString() + @Field(() => String) + @Column({ ...VAR_CHAR }) + public currency!: string; + + @IsDecimal() + @Field(() => Float) + @Column({ type: 'decimal', precision: 10, scale: 0 }) + public amount!: number; + + @IsString() + @Length(3, 3) + @Field(() => String) + @Column({ type: 'varchar', length: 3 }) + public currencyCode!: string; + + @IsDecimal() + @Field(() => Float) + @Column({ type: 'decimal', precision: 10, scale: 6 }) + public rate!: number; +} diff --git a/fullstack/task/packages/server/src/entities/index.ts b/fullstack/task/packages/server/src/entities/index.ts index 6b536a4a..7fe23f2a 100644 --- a/fullstack/task/packages/server/src/entities/index.ts +++ b/fullstack/task/packages/server/src/entities/index.ts @@ -1 +1,3 @@ export * from './example.entity'; +export * from './exchange-rate.entity'; +export * from './cache-metadata.entity'; diff --git a/fullstack/task/packages/server/src/main.ts b/fullstack/task/packages/server/src/main.ts index f3750f7d..eed0779b 100644 --- a/fullstack/task/packages/server/src/main.ts +++ b/fullstack/task/packages/server/src/main.ts @@ -7,6 +7,7 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.enableCors(); app.useGlobalPipes(new ValidationPipe({ whitelist: false, transform: false })); useContainer(app.select(AppModule), { fallbackOnErrors: true }); diff --git a/fullstack/task/packages/server/src/migrations/1758100643630-create-table-exchange_rates.ts b/fullstack/task/packages/server/src/migrations/1758100643630-create-table-exchange_rates.ts new file mode 100644 index 00000000..733237d2 --- /dev/null +++ b/fullstack/task/packages/server/src/migrations/1758100643630-create-table-exchange_rates.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class createTableExchangeRates1758100643630 implements MigrationInterface { + + 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, + "country" character varying(255) NOT NULL, + "currency" character varying(255) NOT NULL, + "amount" numeric(10, 2) NOT NULL, + "currencyCode" character varying(255) NOT NULL, + "rate" numeric(10, 2) NOT NULL, + CONSTRAINT "PK_876duygd68as6d8a6d8as768" PRIMARY KEY ("id") + )`); + + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_exchange_rates_currency_code" ON "exchange_rates" ("currencyCode") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_exchange_rates_currency_code"`); + await queryRunner.query(`DROP TABLE "exchange_rates"`); + } + +} diff --git a/fullstack/task/packages/server/src/migrations/1758102061344-create-cache-metadata-table.ts b/fullstack/task/packages/server/src/migrations/1758102061344-create-cache-metadata-table.ts new file mode 100644 index 00000000..7c37d738 --- /dev/null +++ b/fullstack/task/packages/server/src/migrations/1758102061344-create-cache-metadata-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCacheMetadataTable1758102061344 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE cache_metadata ( + "cacheKey" VARCHAR(50) PRIMARY KEY, + "lastFetchedAt" TIMESTAMPTZ NOT NULL + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS cache_metadata`); + } +} diff --git a/fullstack/task/packages/server/src/services/currency-fetch/currency-fetch.module.ts b/fullstack/task/packages/server/src/services/currency-fetch/currency-fetch.module.ts new file mode 100644 index 00000000..20a60f6a --- /dev/null +++ b/fullstack/task/packages/server/src/services/currency-fetch/currency-fetch.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CurrencyFetchService } from './currency-fetch.service'; +import { ExchangeRate, CacheMetadata } from '../../entities'; + +@Module({ + imports: [TypeOrmModule.forFeature([ExchangeRate, CacheMetadata])], + providers: [CurrencyFetchService], + exports: [CurrencyFetchService], +}) +export class CurrencyFetchModule {} diff --git a/fullstack/task/packages/server/src/services/currency-fetch/currency-fetch.service.ts b/fullstack/task/packages/server/src/services/currency-fetch/currency-fetch.service.ts new file mode 100644 index 00000000..8501c6d0 --- /dev/null +++ b/fullstack/task/packages/server/src/services/currency-fetch/currency-fetch.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, QueryRunner } from 'typeorm'; +import axios from 'axios'; +import { ExchangeRate, CacheMetadata } from '../../entities'; + +interface CNBExchangeRateData { + country: string; + currency: string; + amount: number; + currencyCode: string; + rate: number; +} + +@Injectable() +export class CurrencyFetchService { + private readonly logger = new Logger(CurrencyFetchService.name); + private readonly CNB_URL = 'https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt'; + private readonly CACHE_KEY = 'exchange_rates'; + private readonly CACHE_DURATION_MINUTES = 5; + + constructor( + @InjectRepository(ExchangeRate) + private readonly exchangeRateRepository: Repository, + @InjectRepository(CacheMetadata) + private readonly cacheMetadataRepository: Repository, + ) {} + + public async isCacheValid(): Promise { + try { + const cacheRecord = await this.cacheMetadataRepository.findOne({ + where: { cacheKey: this.CACHE_KEY } + }); + + if (!cacheRecord) { + return false; + } + + const cacheExpiry = new Date(cacheRecord.lastFetchedAt.getTime() + this.CACHE_DURATION_MINUTES * 60 * 1000); + + return new Date() < cacheExpiry; + } catch (error) { + this.logger.error('Error checking cache validity', error); + return false; + } + } + + public async getCachedExchangeRates(): Promise { + try { + const isCacheValid = await this.isCacheValid(); + if (!isCacheValid) { + await this.fetchAndStoreExchangeRates(); + } + + const exchangeRates = await this.exchangeRateRepository.find({ + order: { currencyCode: 'ASC' } + }); + + return exchangeRates; + } catch (error) { + this.logger.error('Error fetching cached exchange rates', error); + throw error; + } + } + + public async fetchAndStoreExchangeRates(): Promise { + try { + const exchangeRates = await this.fetchFromCNB(); + await this.storeExchangeRates(exchangeRates); + } catch (error) { + this.logger.error('Failed to fetch and store exchange rates', error); + throw error; + } + } + + private async fetchFromCNB(): Promise { + try { + const response = await axios.get(this.CNB_URL); + + if (response.status !== 200) { + throw new Error(`CNB API returned ${response.status}: ${response.statusText}`); + } + + return this.parseCNBData(response.data); + } catch (error) { + this.logger.error('Error fetching from CNB API', error); + throw error; + } + } + + private parseCNBData(textData: string): CNBExchangeRateData[] { + const lines = textData.trim().split('\n'); + const dataLines = lines.slice(2); + + const exchangeRates: CNBExchangeRateData[] = []; + + for (const line of dataLines) { + if (line.trim()) { + const parts = line.split('|'); + + if (parts.length >= 5) { + exchangeRates.push({ + country: parts[0].trim(), + currency: parts[1].trim(), + amount: parseInt(parts[2].trim()), + currencyCode: parts[3].trim(), + rate: parseFloat(parts[4].trim()), + }); + } + } + } + + return exchangeRates; + } + + private async storeExchangeRates(exchangeRates: CNBExchangeRateData[]): Promise { + const queryRunner: QueryRunner = this.exchangeRateRepository.manager.connection.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const exchangeRateEntities = exchangeRates.map(rateData => ({ + country: rateData.country, + currency: rateData.currency, + amount: rateData.amount, + currencyCode: rateData.currencyCode, + rate: rateData.rate, + })); + + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(ExchangeRate) + .values(exchangeRateEntities) + .orUpdate(['country', 'currency', 'amount', 'rate', 'updatedAtUtc'], ['currencyCode']) + .execute(); + + + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(CacheMetadata) + .values({ + cacheKey: this.CACHE_KEY, + lastFetchedAt: new Date(), + }) + .orUpdate(['lastFetchedAt'], ['cacheKey']) + .execute(); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error('Error storing exchange rates, transaction rolled back', error); + throw error; + } finally { + await queryRunner.release(); + } + } +} 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..c19a0485 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 { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateResolver } from './exchange-rate.resolver'; +import { CurrencyFetchModule } from '../currency-fetch/currency-fetch.module'; @Module({ - imports: [], + imports: [ + CurrencyFetchModule, + ], 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..8a8a6906 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,13 @@ import { Query, Resolver } from '@nestjs/graphql'; import { ExchangeRateService } from './exchange-rate.service'; +import { ExchangeRate } from '../../entities'; @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(() => [ExchangeRate]) + async exchangeRatesList(): Promise { + 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..33aaa376 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,15 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ExchangeRate, CacheMetadata } from '../../entities'; +import { CurrencyFetchService } from '../currency-fetch/currency-fetch.service'; @Injectable() export class ExchangeRateService { - public getExchangeRates = async () => { - // TODO: Implement the fetching and parsing of the exchange rates. - // Use this method in the resolver. - return []; - }; + constructor( + private readonly currencyFetchService: CurrencyFetchService, + ) {} + + public async getExchangeRates(): Promise { + return await this.currencyFetchService.getCachedExchangeRates(); + } }