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
4 changes: 4 additions & 0 deletions fullstack/task/packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"version": "1.0.0",
"dependencies": {
"@apollo/client": "3.7.10",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.3.0",
"@mui/material": "^6.3.0",
"graphql": "16.6.0",
"graphql-tag": "2.12.6",
"graphql-ws": "5.12.0",
Expand Down
12 changes: 9 additions & 3 deletions fullstack/task/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
function App() {
return <p>TODO</p>;
}
import { ExchangeRates } from './ExchangeRates';

const App = () => {
return (
<div>
<ExchangeRates />
</div>
);
};

export default App;
78 changes: 78 additions & 0 deletions fullstack/task/packages/client/src/ExchangeRates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useQuery } from '@apollo/client';
// eslint-disable-next-line import/no-extraneous-dependencies
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from '@mui/material';
import { useEffect, useState } from 'react';
import { Loader } from './components/loader/Loader';
import { ExchangeRate, GET_EXCHANGE_RATES } from './graphql/queries/exchange-rates';

export const ExchangeRates = () => {
const { data, loading, error } = useQuery(GET_EXCHANGE_RATES);
const [lastFetched, setLastFetched] = useState<Date | null>(null);
const [elapsedTime, setElapsedTime] = useState<string>(`Last updated just now`);

useEffect(() => {
if (data) {
setLastFetched(new Date());
}
}, [data]);

useEffect(() => {
const interval = setInterval(() => {
if (lastFetched) {
const timeDiff = new Date().getTime() - lastFetched.getTime();
const minutesAgo = Math.floor(timeDiff / 1000 / 60);
setElapsedTime(`Last fetched ${minutesAgo} minute(s) ago`);
}
}, 60000);

return () => clearInterval(interval);
}, [lastFetched]);

if (loading) return <Loader />;

if (error) return <Typography color="error">Error: {error.message}</Typography>;

return (
<div style={{ padding: 20 }}>
<Typography variant="h4" gutterBottom>
Exchange Rates
</Typography>
<Typography variant="subtitle1" gutterBottom>
{elapsedTime}
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Country</TableCell>
<TableCell>Currency</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Currency Code</TableCell>
<TableCell>Rate</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.exchangeRates.map((rate: ExchangeRate) => (
<TableRow key={rate.currencyCode}>
<TableCell>{rate.country}</TableCell>
<TableCell>{rate.currency}</TableCell>
<TableCell>{rate.amount}</TableCell>
<TableCell>{rate.currencyCode}</TableCell>
<TableCell>{rate.rate}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
};
17 changes: 17 additions & 0 deletions fullstack/task/packages/client/src/components/loader/Loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { CircularProgress } from '@mui/material';

export const Loader = () => {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<CircularProgress />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { gql } from '@apollo/client';

export interface ExchangeRate {
validFor: string;
order: number;
country: string;
currency: string;
amount: number;
currencyCode: string;
rate: number;
}

export const GET_EXCHANGE_RATES = gql`
query GetExchangeRates {
exchangeRates {
id
validFor
order
country
currency
amount
currencyCode
rate
}
}
`;
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
26 changes: 20 additions & 6 deletions fullstack/task/packages/server/scripts/db-create.sh
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
echo "Removing old docker container..."
(docker stop dishboard-dev-task-db && docker kill dishboard-dev-task-db || :) && (docker rm dishboard-dev-task-db || :)
echo "Removing old docker containers..."
(docker stop dishboard-dev-task-db pgadmin || :) && (docker rm dishboard-dev-task-db pgadmin || :)

echo "Creating a new instance..."
echo "Creating Docker network..."
docker network create pg-network || echo "Network pg-network already exists."

echo "Starting PostgreSQL container..."
docker run \
--name dishboard-dev-task-db \
-e POSTGRES_PASSWORD=postgres \
-e PGPASSWORD=postgres \
-p 5430:5432 \
--network pg-network \
-d postgres

echo "Starting pgAdmin container..."
docker run \
--name pgadmin \
-e PGADMIN_DEFAULT_EMAIL=pgadminuser@gmail.com \
-e PGADMIN_DEFAULT_PASSWORD=Database123! \
-p 8080:80 \
--network pg-network \
-d dpage/pgadmin4:latest

echo "Waiting for the database to start..."
sleep 3
sleep 5

echo "Creating the database..."
echo "CREATE DATABASE dev;" | docker exec -i dishboard-dev-task-db psql -U postgres
echo "\l" | docker exec -i dishboard-dev-task-db psql -U postgres
docker exec -i dishboard-dev-task-db psql -U postgres -c "CREATE DATABASE dev;"

echo "PostgreSQL and pgAdmin setup complete. pgAdmin is available at http://localhost:8080/."
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
import { Column, Entity } from 'typeorm';
import { EntityWithMeta } from '../common';

@ObjectType()
@Entity({ name: 'exchange_rates' })
export class ExchangeRate extends EntityWithMeta {
@Column()
@Field()
validFor!: string;

@Column()
@Field(() => Int)
order!: number;

@Column()
@Field()
country!: string;

@Column()
@Field()
currency!: string;

@Column()
@Field(() => Float)
amount!: number;

@Column()
@Field()
currencyCode!: string;

@Column('decimal')
@Field(() => Float)
rate!: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class createExchnageRatesTable1735559537285 implements MigrationInterface {
name = 'createExchnageRatesTable1735559537285'

public async up(queryRunner: QueryRunner): Promise<void> {
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, "validFor" character varying NOT NULL, "order" integer NOT NULL, "country" character varying NOT NULL, "currency" character varying NOT NULL, "amount" integer NOT NULL, "currencyCode" character varying NOT NULL, "rate" numeric NOT NULL, CONSTRAINT "PK_33a614bad9e61956079d817ebe2" PRIMARY KEY ("id"))`);
}

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

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { ExchangeRateService } from './exchange-rate.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeRate } from '../../entities/exchange-rate.entity';
import { ExchangeRateResolver } from './exchange-rate.resolver';
import { ExchangeRateService } from './exchange-rate.service';

@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,14 @@
import { Query, Resolver } from '@nestjs/graphql';
import { ExchangeRate } from '../../entities/exchange-rate.entity';
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<string> {
return 'Hello';
@Query(() => [ExchangeRate])
async exchangeRates(): Promise<ExchangeRate[]> {
const rates = await this.exchangeRateService.getExchangeRates();
return rates || [];
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { MoreThanOrEqual, Repository } from 'typeorm';
import { ExchangeRate } from '../../entities/exchange-rate.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 cacheLifetimeInMinutes = 5;
private exchangeRatesApiUrl = `https://api.cnb.cz/cnbapi/exrates`;

return [];
constructor(
@InjectRepository(ExchangeRate)
private readonly exchangeRateRepository: Repository<ExchangeRate>
) {}

public getExchangeRates = async (): Promise<ExchangeRate[]> => {
const cachedRates = await this.getCachedRates();

if (cachedRates?.length) {
return cachedRates;
}

const response = await axios.get(`${this.exchangeRatesApiUrl}/daily`);

const exchangeRates: ExchangeRate[] = response.data.rates.map((rate: any) => ({
validFor: rate.validFor,
order: rate.order,
country: rate.country,
currency: rate.currency,
amount: rate.amount,
currencyCode: rate.currencyCode,
rate: rate.rate,
}));

await this.saveExchangeRates(exchangeRates);

const rates = await this.exchangeRateRepository.find();

return rates;
};

private saveExchangeRates = async (exchangeRates: ExchangeRate[]): Promise<void> => {
await this.exchangeRateRepository.delete({});
await this.exchangeRateRepository.save(exchangeRates);
};

private getCachedRates = async (): Promise<ExchangeRate[] | null> => {
const cacheExpirationTime = new Date();

cacheExpirationTime.setMinutes(
cacheExpirationTime.getMinutes() - this.cacheLifetimeInMinutes
);

const cachedRates = await this.exchangeRateRepository.find({
where: {
createdAtUtc: MoreThanOrEqual(cacheExpirationTime),
},
});

return cachedRates || null;
};
}