diff --git a/fullstack/task/packages/client/src/App.tsx b/fullstack/task/packages/client/src/App.tsx index f62d2a02..99b8e099 100644 --- a/fullstack/task/packages/client/src/App.tsx +++ b/fullstack/task/packages/client/src/App.tsx @@ -1,5 +1,8 @@ +import React from 'react'; +import ExchangeRatesTable from './components/ExchangeRatesTable'; + function App() { - return

TODO

; + return ; } export default App; diff --git a/fullstack/task/packages/client/src/components/ExchangeRatesTable/ExchangeRatesTable.styles.ts b/fullstack/task/packages/client/src/components/ExchangeRatesTable/ExchangeRatesTable.styles.ts new file mode 100644 index 00000000..988a0bf5 --- /dev/null +++ b/fullstack/task/packages/client/src/components/ExchangeRatesTable/ExchangeRatesTable.styles.ts @@ -0,0 +1,72 @@ +import styled from 'styled-components'; + +export const ExchangeRatesContainer = styled.div` + max-width: 1200px; + margin: 0 auto; + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +`; + +export const ExchangeRatesHeader = styled.h1` + text-align: center; + color: #333; + margin-bottom: 30px; +`; + +export const ExchangeRatesTableContent = styled.table` + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 8px; + overflow: hidden; +`; + +export const ExchangeRatesTableHeader = styled.th` + background-color: #f8f9fa; + padding: 12px; + text-align: left; + font-weight: 600; + color: #495057; + border-bottom: 2px solid #dee2e6; +`; + +export const ExchangeRatesTableCell = styled.td` + padding: 12px; + border-bottom: 1px solid #dee2e6; +`; + +export const ExchangeRatesTableRow = styled.tr` + &:hover { + background-color: #f8f9fa; + } +`; + +export const ExchangeRatesLoadingMessage = styled.div` + text-align: center; + padding: 40px; + color: #6c757d; + font-size: 16px; +`; + +export const ExchangeRatesErrorMessage = styled.div` + text-align: center; + padding: 40px; + color: #dc3545; + font-size: 16px; + background-color: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 8px; + margin-bottom: 20px; +`; + +export const ExchangeRatesCacheInfo = styled.div` + text-align: center; + padding: 15px; + background-color: #e7f3ff; + border: 1px solid #b3d9ff; + border-radius: 8px; + color: #0066cc; + font-size: 14px; + margin-bottom: 20px; +`; diff --git a/fullstack/task/packages/client/src/components/ExchangeRatesTable/ExchangeRatesTable.tsx b/fullstack/task/packages/client/src/components/ExchangeRatesTable/ExchangeRatesTable.tsx new file mode 100644 index 00000000..b11a7278 --- /dev/null +++ b/fullstack/task/packages/client/src/components/ExchangeRatesTable/ExchangeRatesTable.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { useExchangeRates } from '../../hooks'; +import { formatDate, formatRate } from '../../utils'; +import { + ExchangeRatesContainer, + ExchangeRatesHeader, + ExchangeRatesTableContent, + ExchangeRatesTableHeader, + ExchangeRatesTableCell, + ExchangeRatesTableRow, + ExchangeRatesLoadingMessage, + ExchangeRatesErrorMessage, + ExchangeRatesCacheInfo, +} from './ExchangeRatesTable.styles'; + +interface ExchangeRatesTableProps {} + +const ExchangeRatesTable = ({}: ExchangeRatesTableProps) => { + const { exchangeRates, loading, error } = useExchangeRates(); + + if (loading) { + return ( + + Exchange Rates + Loading exchange rates... + + ); + } + + if (error) { + return ( + + Exchange Rates + + Error loading exchange rates: {error.message} + + + ); + } + + const latestFetchTime = exchangeRates.length > 0 ? exchangeRates[0].fetchedAt : null; + + return ( + + Exchange Rates + + {latestFetchTime && ( + + Last updated: {formatDate(latestFetchTime)} + + )} + + + + + Country + Currency + Amount + Code + Rate (CZK) + + + + {exchangeRates.map((rate) => ( + + {rate.country} + {rate.currency} + {rate.amount} + {rate.code} + {formatRate(rate.rate)} + + ))} + + + + ); +}; + +export default ExchangeRatesTable; \ No newline at end of file diff --git a/fullstack/task/packages/client/src/components/ExchangeRatesTable/index.ts b/fullstack/task/packages/client/src/components/ExchangeRatesTable/index.ts new file mode 100644 index 00000000..4764cb99 --- /dev/null +++ b/fullstack/task/packages/client/src/components/ExchangeRatesTable/index.ts @@ -0,0 +1 @@ +export { default } from './ExchangeRatesTable'; diff --git a/fullstack/task/packages/client/src/graphql/exchange-rates/exchange-rates.queries.ts b/fullstack/task/packages/client/src/graphql/exchange-rates/exchange-rates.queries.ts new file mode 100644 index 00000000..22edd56f --- /dev/null +++ b/fullstack/task/packages/client/src/graphql/exchange-rates/exchange-rates.queries.ts @@ -0,0 +1,15 @@ +import { gql } from 'graphql-tag'; + +export const GET_EXCHANGE_RATES = gql` + query GetExchangeRates { + exchangeRates { + id + country + currency + amount + code + rate + fetchedAt + } + } +`; diff --git a/fullstack/task/packages/client/src/graphql/exchange-rates/index.ts b/fullstack/task/packages/client/src/graphql/exchange-rates/index.ts new file mode 100644 index 00000000..1cbe4588 --- /dev/null +++ b/fullstack/task/packages/client/src/graphql/exchange-rates/index.ts @@ -0,0 +1 @@ +export * from './exchange-rates.queries'; diff --git a/fullstack/task/packages/client/src/graphql/index.ts b/fullstack/task/packages/client/src/graphql/index.ts new file mode 100644 index 00000000..dfab0c88 --- /dev/null +++ b/fullstack/task/packages/client/src/graphql/index.ts @@ -0,0 +1 @@ +export * from './exchange-rates'; diff --git a/fullstack/task/packages/client/src/hooks/index.ts b/fullstack/task/packages/client/src/hooks/index.ts new file mode 100644 index 00000000..47c24f9d --- /dev/null +++ b/fullstack/task/packages/client/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useExchangeRates'; diff --git a/fullstack/task/packages/client/src/hooks/useExchangeRates.ts b/fullstack/task/packages/client/src/hooks/useExchangeRates.ts new file mode 100644 index 00000000..607ddf1c --- /dev/null +++ b/fullstack/task/packages/client/src/hooks/useExchangeRates.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@apollo/client'; +import { GET_EXCHANGE_RATES } from '../graphql'; + +export interface ExchangeRate { + id: string; + country: string; + currency: string; + amount: number; + code: string; + rate: number; + fetchedAt: string; +} + +export interface UseExchangeRatesResult { + exchangeRates: ExchangeRate[]; + loading: boolean; + error: Error | null; + refetch: () => void; +} + +export const useExchangeRates = (): UseExchangeRatesResult => { + const { loading, error, data, refetch } = useQuery(GET_EXCHANGE_RATES); + + return { + exchangeRates: data?.exchangeRates || [], + loading, + error: error || null, + refetch, + }; +}; 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/client/src/utils/dateUtils.ts b/fullstack/task/packages/client/src/utils/dateUtils.ts new file mode 100644 index 00000000..10cb8708 --- /dev/null +++ b/fullstack/task/packages/client/src/utils/dateUtils.ts @@ -0,0 +1,11 @@ +export const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +}; diff --git a/fullstack/task/packages/client/src/utils/index.ts b/fullstack/task/packages/client/src/utils/index.ts new file mode 100644 index 00000000..35595953 --- /dev/null +++ b/fullstack/task/packages/client/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './dateUtils'; +export * from './numberUtils'; diff --git a/fullstack/task/packages/client/src/utils/numberUtils.ts b/fullstack/task/packages/client/src/utils/numberUtils.ts new file mode 100644 index 00000000..d4d441cc --- /dev/null +++ b/fullstack/task/packages/client/src/utils/numberUtils.ts @@ -0,0 +1,3 @@ +export const formatRate = (rate: number): string => { + return rate.toFixed(4); +}; diff --git a/fullstack/task/packages/server/.env b/fullstack/task/packages/server/.env index 32fba8c8..6472af3a 100644 --- a/fullstack/task/packages/server/.env +++ b/fullstack/task/packages/server/.env @@ -9,3 +9,4 @@ GQL_PLAYGROUND=true GQL_INTROSPECTION=true PORT=4001 +CNB_API_URL=https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.txt \ No newline at end of file diff --git a/fullstack/task/packages/server/schema.gql b/fullstack/task/packages/server/schema.gql index 2453a887..203c2cc1 100644 --- a/fullstack/task/packages/server/schema.gql +++ b/fullstack/task/packages/server/schema.gql @@ -17,8 +17,22 @@ 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! + fetchedAt: DateTime! +} + type Query { - exchangeRates: String! + exchangeRates: [ExchangeRate!]! exampleByName(name: String!): Example } diff --git a/fullstack/task/packages/server/src/common/entities/entity-with-meta.ts b/fullstack/task/packages/server/src/common/entities/entity-with-meta.ts index 97d4f35f..04a5979b 100644 --- a/fullstack/task/packages/server/src/common/entities/entity-with-meta.ts +++ b/fullstack/task/packages/server/src/common/entities/entity-with-meta.ts @@ -34,6 +34,5 @@ export const omittedEntityMetaColumns: (keyof EntityWithMeta)[] = [ 'version', 'updatedAtUtc', 'createdAtUtc', - 'deleteDateUtc', - 'deleteDateUtc', + 'deleteDateUtc' ]; 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..6e1d918b --- /dev/null +++ b/fullstack/task/packages/server/src/entities/exchange-rate.entity.ts @@ -0,0 +1,41 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IsDate, IsNumber, IsString, MinLength } from 'class-validator'; +import { Column, Entity } from 'typeorm'; +import { EntityWithMeta } from '../common'; +import { VAR_CHAR } from './constants'; + +@ObjectType() +@Entity() +export class ExchangeRate extends EntityWithMeta { + @IsString() + @MinLength(1) + @Field(() => String) + @Column({ ...VAR_CHAR }) + country!: string; + + @IsString() + @MinLength(1) + @Field(() => String) + @Column({ ...VAR_CHAR }) + currency!: string; + + @IsNumber() + @Field(() => Number) + @Column({ type: 'int' }) + amount!: number; + + @IsString() + @Field(() => String) + @Column({ ...VAR_CHAR }) + code!: string; + + @IsNumber() + @Field(() => Number) + @Column({ type: 'decimal', precision: 10, scale: 4 }) + rate!: number; + + @IsDate() + @Field(() => Date) + @Column({ type: 'timestamp' }) + fetchedAt!: Date; +} diff --git a/fullstack/task/packages/server/src/entities/index.ts b/fullstack/task/packages/server/src/entities/index.ts index 6b536a4a..f7c68b09 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 './constants'; diff --git a/fullstack/task/packages/server/src/migrations/1735689600000-create-exchange-rate-entity.ts b/fullstack/task/packages/server/src/migrations/1735689600000-create-exchange-rate-entity.ts new file mode 100644 index 00000000..c54ffff4 --- /dev/null +++ b/fullstack/task/packages/server/src/migrations/1735689600000-create-exchange-rate-entity.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +export class CreateExchangeRateEntity1735689600000 implements MigrationInterface { + name = 'CreateExchangeRateEntity1735689600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'exchange_rate', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'createdAtUtc', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAtUtc', + type: 'timestamptz', + isNullable: true, + }, + { + name: 'deleteDateUtc', + type: 'timestamptz', + isNullable: true, + }, + { + name: 'version', + type: 'int', + default: 1, + }, + { + name: 'country', + type: 'varchar', + length: '255', + }, + { + name: 'currency', + type: 'varchar', + length: '255', + }, + { + name: 'amount', + type: 'int', + }, + { + name: 'code', + type: 'varchar', + length: '255', + }, + { + name: 'rate', + type: 'decimal', + precision: 10, + scale: 4, + }, + { + name: 'fetchedAt', + type: 'timestamp', + }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('exchange_rate'); + } +} 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..a48fd153 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 { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeRate } from '../../entities'; import { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateResolver } from './exchange-rate.resolver'; @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..1d87914a 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 { ExchangeRate } from '../../entities'; 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 { + 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..58f4f324 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,94 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ExchangeRate } from '../../entities'; @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 cnbApiUrl: string; + private readonly CACHE_LIFETIME_MS = 5 * 60 * 1000; // 5 minutes - return []; + constructor( + @InjectRepository(ExchangeRate) + private readonly exchangeRateRepository: Repository, + private readonly configService: ConfigService, + ) { + this.cnbApiUrl = this.configService.getOrThrow('CNB_API_URL'); + } + + public getExchangeRates = async (): Promise => { + // Check if we have cached data that's still valid + const cachedRates = await this.getCachedRates(); + if (cachedRates.length > 0) { + return cachedRates; + } + + const freshRates = await this.fetchFromCNB(); + + // Clear old cache and save new data + await this.exchangeRateRepository.clear(); + await this.exchangeRateRepository.save(freshRates); + + return freshRates; }; + + private async getCachedRates(): Promise { + const cacheExpiryTime = new Date(Date.now() - this.CACHE_LIFETIME_MS); + + const cachedRates = await this.exchangeRateRepository + .createQueryBuilder('rate') + .where('rate.fetchedAt > :cacheExpiryTime', { cacheExpiryTime }) + .getMany(); + + return cachedRates; + } + + private async fetchFromCNB(): Promise { + try { + const response = await fetch(this.cnbApiUrl); + const text = await response.text(); + + if (!response.ok) { + throw new Error(`CNB API error: ${response.status} ${response.statusText}`); + } + + return this.parseCNBResponse(text); + } catch (error) { + console.error('Error fetching exchange rates from CNB:', error); + throw new Error('Failed to fetch exchange rates from Czech National Bank'); + } + } + + private parseCNBResponse(text: string): ExchangeRate[] { + try { + const lines = text.split('\n'); + const rates: ExchangeRate[] = []; + + for (let i = 2; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + const parts = line.split('|'); + if (parts.length >= 5) { + const [country, currency, amount, code, rate] = parts; + + const exchangeRate = new ExchangeRate(); + exchangeRate.country = country.trim(); + exchangeRate.currency = currency.trim(); + exchangeRate.amount = parseInt(amount.trim()); + exchangeRate.code = code.trim(); + exchangeRate.rate = parseFloat(rate.replace(',', '.')); + exchangeRate.fetchedAt = new Date(); + + rates.push(exchangeRate); + } + } + + return rates; + } catch (error) { + console.error('Error parsing CNB response:', error); + throw new Error('Failed to parse exchange rates data from CNB'); + } + } } diff --git a/fullstack/task/packages/server/tsconfig.json b/fullstack/task/packages/server/tsconfig.json index a5eee942..0f32ac24 100644 --- a/fullstack/task/packages/server/tsconfig.json +++ b/fullstack/task/packages/server/tsconfig.json @@ -1,9 +1,11 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "noEmit": false, - "outDir": "./dist", - "baseUrl": "./" - }, - "include": ["src"] -} +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "baseUrl": "./", + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +}