@accounter/server
Version:
Accounter GraphQL server
193 lines (172 loc) • 6.25 kB
text/typescript
import DataLoader from 'dataloader';
import { format, subHours } from 'date-fns';
import { GraphQLError } from 'graphql';
import { Injectable, Scope } from 'graphql-modules';
import { sql } from '@pgtyped/runtime';
import { Currency } from '../../../shared/enums.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { CoinMarketCapProvider } from '../../app-providers/coinmarketcap.js';
import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js';
import {
IGetCryptoCurrenciesBySymbolQuery,
IGetCryptoCurrenciesBySymbolResult,
IGetRateByCurrencyAndDateQuery,
IGetRateByCurrencyAndDateResult,
IInsertRatesParams,
IInsertRatesQuery,
} from '../types.js';
const getRateByCurrencyAndDate = sql<IGetRateByCurrencyAndDateQuery>`
SELECT *
FROM accounter_schema.crypto_exchange_rates
WHERE coin_symbol = $currency
AND date = $date;`;
const insertRates = sql<IInsertRatesQuery>`
INSERT INTO accounter_schema.crypto_exchange_rates (date, coin_symbol, value, against, sample_date)
VALUES $$rates(date, currency, value, against, sampleDate)
ON CONFLICT (date, coin_symbol, against) DO UPDATE
SET value = EXCLUDED.value
RETURNING *;
`;
const getCryptoCurrenciesBySymbol = sql<IGetCryptoCurrenciesBySymbolQuery>`
SELECT *
FROM accounter_schema.crypto_currencies
WHERE symbol in $$currencySymbols;`;
({
scope: Scope.Operation,
global: true,
})
export class CryptoExchangeProvider {
constructor(
private db: TenantAwareDBClient,
private adminContextProvider: AdminContextProvider,
private coinMarketCap: CoinMarketCapProvider,
) {}
private async getCryptoExchangeRatesFromDB(currency: string, date: Date) {
return getRateByCurrencyAndDate.run({ currency, date }, this.db);
}
private async addRates(rates: IInsertRatesParams['rates']) {
this.clearCache();
return insertRates.run({ rates }, this.db);
}
private addCryptoRateLoader = new DataLoader(
(
keys: readonly {
date: Date;
currency: string;
against?: Currency;
value: number;
sampleDate: Date;
}[],
) => this.addRates(keys.map(rate => ({ ...rate, against: rate.against ?? null }))),
{
cache: false,
},
);
private async getCryptoCurrenciesBySymbol(
symbols: readonly string[],
): Promise<Array<IGetCryptoCurrenciesBySymbolResult | undefined>> {
const currencies = await getCryptoCurrenciesBySymbol.run(
{
currencySymbols: symbols,
},
this.db,
);
return symbols.map(symbol => currencies.find(currency => currency.symbol === symbol));
}
private getCryptoCurrenciesBySymbolLoader = new DataLoader((symbols: readonly string[]) =>
this.getCryptoCurrenciesBySymbol(symbols),
);
private async getCryptoExchangeRatesFromAPI(currencySymbol: string, date: Date) {
// Fetch CoinMarketCap id from DB
const currencyInfo = await this.getCryptoCurrenciesBySymbolLoader.load(currencySymbol);
if (!currencyInfo) {
throw new GraphQLError(`No data found for crypto currency ${currencySymbol}`);
}
const coinmarketcapId = currencyInfo?.coinmarketcap_id;
if (!coinmarketcapId) {
throw new GraphQLError(`No CoinMarketCap id found for crypto currency ${currencySymbol}`);
}
const fromDate = subHours(date, 23);
const from = Math.floor(fromDate.getTime() / 1000);
const to = Math.floor(date.getTime() / 1000);
const ratesObject = await this.coinMarketCap.getExchangeRates(coinmarketcapId, {
fromTimeStamp: from,
toTimeStamp: to,
});
let timestamp: number | undefined = undefined;
let rate: number | undefined = undefined;
for (const [rawTimestamp, rateData] of Object.entries<{ c?: Array<number> } | undefined>(
ratesObject,
)) {
const newTimestamp = Number(rawTimestamp);
if (newTimestamp <= to && rateData?.c?.[0]) {
if (!timestamp || !rate) {
timestamp = newTimestamp;
rate = rateData.c[0];
}
if (newTimestamp > timestamp) {
timestamp = newTimestamp;
rate = rateData.c[0];
}
}
}
if (!rate || !timestamp) {
throw new GraphQLError(`No suitable rate of ${currencySymbol} found in CoinMarketCap`);
}
const sampleDate = new Date(timestamp * 1000);
const { defaultCryptoConversionFiatCurrency } =
await this.adminContextProvider.getVerifiedAdminContext();
// Add rate to DB
await this.addCryptoRateLoader.load({
date,
currency: currencySymbol,
value: rate,
against: defaultCryptoConversionFiatCurrency,
sampleDate,
});
return rate;
}
public getCryptoExchangeRateLoader = new DataLoader<
{ cryptoCurrency: string; date: Date; against?: Currency },
IGetRateByCurrencyAndDateResult,
string
>(
async keys => {
const { defaultCryptoConversionFiatCurrency } =
await this.adminContextProvider.getVerifiedAdminContext();
const rates = await Promise.all(
keys.map(
async ({ cryptoCurrency, date, against = defaultCryptoConversionFiatCurrency }) => {
// Fetch from DB first
const res = await this.getCryptoExchangeRatesFromDB(cryptoCurrency, date);
if (res.length > 0) {
return res[0];
}
// If not found in DB, fetch from API
const rate = await this.getCryptoExchangeRatesFromAPI(cryptoCurrency, date);
if (rate == null) {
return new GraphQLError(
`No data found for ${cryptoCurrency} on ${format(date, 'dd-MM-yyyy')}`,
);
}
return {
date,
coin_symbol: cryptoCurrency,
value: rate.toString(),
against,
} as IGetRateByCurrencyAndDateResult;
},
),
);
return rates;
},
{
cacheKeyFn: ({ cryptoCurrency, date, against }) =>
`${cryptoCurrency}-${date.getTime()}-${against}`,
},
);
public clearCache() {
this.getCryptoCurrenciesBySymbolLoader.clearAll();
this.getCryptoExchangeRateLoader.clearAll();
}
}