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"]
+}