Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions fullstack/task/packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@
"@types/react-dom": "18.0.6",
"@types/styled-components": "^5.1.26",
"@vitejs/plugin-react": "2.1.0",
"autoprefixer": "^10.4.21",
"cross-env": "7.0.3",
"eslint-import-resolver-typescript": "3.5.3",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-react": "7.31.8",
"jest": "28.1.3",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"ts-jest": "28.0.8",
"vite": "3.1.0"
},
Expand Down
6 changes: 6 additions & 0 deletions fullstack/task/packages/client/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
15 changes: 12 additions & 3 deletions fullstack/task/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
function App() {
return <p>TODO</p>;
}
import React from 'react';
import { ExchangeRates } from './components/ExchangeRates';

const App: React.FC = () => {
return (
<div className="min-h-screen bg-blue-100 flex justify-center items-center p-4">
<div className="bg-white p-6 rounded-lg shadow-md max-w-4xl w-full">
<ExchangeRates />
</div>
</div>
);
};

export default App;
87 changes: 87 additions & 0 deletions fullstack/task/packages/client/src/components/ExchangeRates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react';
import { gql, useQuery } from '@apollo/client';

interface ExchangeRate {
country: string;
currency: string;
amount: number;
code: string;
rate: number;
}

interface ExchangeRateData {
exchangeRates: {
fetchedAt: string;
rates: ExchangeRate[];
};
}

const EXCHANGE_RATES_QUERY = gql`
query GetExchangeRates {
exchangeRates {
fetchedAt
rates {
country
currency
amount
code
rate
}
}
}
`;

export const ExchangeRates: React.FC = () => {
const { loading, error, data } = useQuery<ExchangeRateData>(EXCHANGE_RATES_QUERY);
const timeSinceFetch = data
? Math.floor(
(new Date().getTime() - new Date(data.exchangeRates.fetchedAt).getTime()) / 1000
)
: 0;

if (loading) return <div className="p-4 text-center">Loading...</div>;
if (error) return <div className="p-4 text-center text-red-500">Error: {error.message}</div>;

return (
<div className="w-full">
<h1 className="text-2xl font-bold mb-2">Exchange Rates</h1>
<p className="text-gray-600 mb-4">Last updated: {timeSinceFetch} seconds ago</p>
<div className="overflow-x-auto w-full">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="bg-gray-200 text-gray-700 font-semibold text-left p-3 border border-gray-300">
Country
</th>
<th className="bg-gray-200 text-gray-700 font-semibold text-left p-3 border border-gray-300">
Currency
</th>
<th className="bg-gray-200 text-gray-700 font-semibold text-left p-3 border border-gray-300">
Amount
</th>
<th className="bg-gray-200 text-gray-700 font-semibold text-left p-3 border border-gray-300">
Code
</th>
<th className="bg-gray-200 text-gray-700 font-semibold text-left p-3 border border-gray-300">
Rate
</th>
</tr>
</thead>
<tbody>
{data?.exchangeRates.rates.map((rate) => (
<tr key={rate.code} className="hover:bg-blue-50">
<td className="p-3 border border-gray-300">{rate.country}</td>
<td className="p-3 border border-gray-300">{rate.currency}</td>
<td className="p-3 border border-gray-300">{rate.amount}</td>
<td className="p-3 border border-gray-300">{rate.code}</td>
<td className="p-3 border border-gray-300 font-medium">
{rate.rate.toFixed(3)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
3 changes: 3 additions & 0 deletions fullstack/task/packages/client/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
3 changes: 2 additions & 1 deletion fullstack/task/packages/client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
import App from './App';
import './index.css';

const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
uri: 'http://localhost:4001/graphql',
cache: new InMemoryCache(),
});

Expand Down
10 changes: 10 additions & 0 deletions fullstack/task/packages/client/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
2 changes: 2 additions & 0 deletions fullstack/task/packages/server/.env
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ GQL_DEBUG=true
GQL_PLAYGROUND=true
GQL_INTROSPECTION=true

BANK_URL=https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.txt

PORT=4001
17 changes: 16 additions & 1 deletion fullstack/task/packages/server/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,23 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
"""
scalar DateTime

type ExchangeRate {
id: ID!
country: String!
currency: String!
amount: Float!
code: String!
rate: Float!
createdAt: DateTime!
}

type ExchangeRateCache {
fetchedAt: DateTime!
rates: [ExchangeRate!]!
}

type Query {
exchangeRates: String!
exchangeRates: ExchangeRateCache!
exampleByName(name: String!): Example
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Field, Float, ID, ObjectType } from '@nestjs/graphql';
import { IsNumber, IsString, MinLength } from 'class-validator';
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { VAR_CHAR } from './constants';

@ObjectType()
@Entity()
export class ExchangeRate {
@Field(() => ID)
@PrimaryGeneratedColumn('uuid')
public id!: string;

@IsString()
@MinLength(1)
@Field(() => String)
@Column({ ...VAR_CHAR })
public country!: string;

@IsString()
@MinLength(1)
@Field(() => String)
@Column({ ...VAR_CHAR })
public currency!: string;

@IsNumber()
@Field(() => Float)
@Column({ type: 'float' })
public amount!: number;

@IsString()
@MinLength(1)
@Field(() => String)
@Column({ ...VAR_CHAR })
@Index()
public code!: string;

@IsNumber()
@Field(() => Float)
@Column({ type: 'float' })
public rate!: number;

@Field(() => Date)
@Column({ type: 'timestamp' })
public createdAt!: Date;
}

@ObjectType()
export class ExchangeRateCache {
@Field(() => Date)
public fetchedAt!: Date;

@Field(() => [ExchangeRate])
public rates!: ExchangeRate[];
}
1 change: 1 addition & 0 deletions fullstack/task/packages/server/src/entities/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './example.entity';
export * from './exchange-rate.entity';
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class addExchangeRates1744935284746 implements MigrationInterface {
name = 'addExchangeRates1744935284746'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "exchange_rate" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "country" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "amount" double precision NOT NULL, "code" character varying(255) NOT NULL, "rate" double precision NOT NULL, "createdAt" TIMESTAMP NOT NULL, CONSTRAINT "PK_5c5d27d2b900ef6cdeef0398472" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_9c87dc76495d95ed8fd3a56809" ON "exchange_rate" ("code") `);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_9c87dc76495d95ed8fd3a56809"`);
await queryRunner.query(`DROP TABLE "exchange_rate"`);
}

}
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Query, Resolver } from '@nestjs/graphql';
import { ExchangeRateCache } from '../../entities';
import { ExchangeRateService } from './exchange-rate.service';

@Resolver()
export class ExchangeRateResolver {
constructor(private readonly exchangeRateService: ExchangeRateService) {}

// TODO: Implement a GraphQL Query that returns the exchange rates
@Query(() => String)
async exchangeRates(): Promise<string> {
return 'Hello';
@Query(() => ExchangeRateCache)
async exchangeRates(): Promise<ExchangeRateCache> {
return this.exchangeRateService.getExchangeRates();
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,58 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
import { ExchangeRate, ExchangeRateCache } 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.
constructor(
@InjectRepository(ExchangeRate)
private readonly exchangeRateRepository: Repository<ExchangeRate>,
private readonly configService: ConfigService
) {}

return [];
};
private async fetchFromCNB(): Promise<ExchangeRate[]> {
const bankUrl = this.configService.get<string>('BANK_URL') ?? '';
const response = await axios.get(bankUrl);
const lines = response.data.split('\n');
const rates = lines.slice(2).filter((line: string) => line.trim());

return rates.map((line: string) => {
const [country, currency, amount, code, rate] = line.split('|');
return {
country,
currency,
amount: parseFloat(amount),
code,
rate: parseFloat(rate.replace(',', '.')),
createdAt: new Date(),
} as ExchangeRate;
});
}

public async getExchangeRates(): Promise<ExchangeRateCache> {
const latestRates = await this.exchangeRateRepository.find({
order: { createdAt: 'DESC' },
});
if (
latestRates.length > 0 &&
new Date().getTime() - new Date(latestRates[0].createdAt).getTime() < 5 * 60 * 1000
) {
return {
fetchedAt: latestRates[0].createdAt,
rates: latestRates,
};
}

const freshRates = await this.fetchFromCNB();
await this.exchangeRateRepository.clear();
const savedRates = await this.exchangeRateRepository.save(freshRates);

return {
fetchedAt: new Date(),
rates: savedRates,
};
}
}
Loading