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
6 changes: 6 additions & 0 deletions fullstack/task/packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion fullstack/task/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ExchangeRateTable from "./components/ExchangeRateTable";

function App() {
return <p>TODO</p>;
return <ExchangeRateTable />;
}

export default App;
Original file line number Diff line number Diff line change
@@ -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 <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;

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 (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f9f9f9',
}}
>
<Paper
sx={{
padding: 3,
boxShadow: 3,
borderRadius: 2,
}}
>
<Box
sx={{
marginBottom: 2,
padding: 1,
backgroundColor: '#f5f5f5',
borderRadius: '4px',
}}
>
<Typography variant="subtitle1" color="textSecondary">
<strong>Last Fetched:</strong> {lastFetched}
</Typography>
</Box>
<DataGrid
rows={rows}
columns={columns}
loading={loading}
slots={{ toolbar: GridToolbar }}
/>
</Paper>
</Box>
);
};

export default ExchangeRateTable;
14 changes: 14 additions & 0 deletions fullstack/task/packages/client/src/graphql/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';

export const GET_EXCHANGE_RATES = gql`
query {
exchangeRates {
rate
currency
country
amount
code
updatedAtUtc
}
}
`;
2 changes: 1 addition & 1 deletion fullstack/task/packages/client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down
2 changes: 2 additions & 0 deletions fullstack/task/packages/server/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions fullstack/task/packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion fullstack/task/packages/server/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions fullstack/task/packages/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { config } from 'dotenv';

config();

const {
EXCHANGE_RATE_API_URL: exchangeRateApi,
} = process.env;

export const exchangeRateConfig = {
apiUrl: exchangeRateApi,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './exchange-rates-config';
3 changes: 3 additions & 0 deletions fullstack/task/packages/server/src/entities/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class createExchangeRatesTable1735573865264 implements MigrationInterface {
name = 'createExchangeRatesTable1735573865264'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exchange_rate" ALTER COLUMN "rate" TYPE numeric`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exchange_rate" ALTER COLUMN "rate" TYPE numeric(3,3)`);
}

}
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Repository } from 'typeorm';
import { ExchangeRate } from 'src/entities/exchange-rates.entity';

export class ExchangeRateRepository extends Repository<ExchangeRate> {}
Original file line number Diff line number Diff line change
@@ -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<string> {
return 'Hello';
}
@Query(() => [ExchangeRate])
async exchangeRates(): Promise<ExchangeRate[]> {
const exchangeRates = await this.exchangeRateService.getExchangeRates();
return exchangeRates;
}
}
Original file line number Diff line number Diff line change
@@ -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<ExchangeRate>
) {}

public getExchangeRates = async (): Promise<ExchangeRate[]> => {
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<string>('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<ExchangeRate[]> => {
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<void> => {
await this.exchangeRateRepository.clear();

await this.exchangeRateRepository.save(rates);
};
}