UNPKG

@accounter/server

Version:

203 lines (180 loc) • 6.5 kB
import DataLoader from 'dataloader'; import { format, subHours } from 'date-fns'; import { GraphQLError } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; import { DBProvider } from '@modules/app-providers/db.provider.js'; import { sql } from '@pgtyped/runtime'; import { DEFAULT_CRYPTO_FIAT_CONVERSION_CURRENCY } from '@shared/constants'; import { Currency } from '@shared/gql-types'; import { fetch as rawFetch } from '@whatwg-node/fetch'; 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, coin_symbol, value, against, sample_date) 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;`; @Injectable({ scope: Scope.Singleton, global: true, }) export class CryptoExchangeProvider { fiatCurrency = DEFAULT_CRYPTO_FIAT_CONVERSION_CURRENCY; constructor(private dbProvider: DBProvider) {} public async getCryptoExchangeRatesFromDB(currency: string, date: Date) { return getRateByCurrencyAndDate.run({ currency, date }, this.dbProvider); } private async addRates(rates: IInsertRatesParams['rates']) { return insertRates.run({ rates }, this.dbProvider); } public addCryptoRateLoader = new DataLoader( ( keys: readonly { date: Date; currency: string; against?: Currency; value: number; sampleDate: Date; }[], ) => this.addRates( keys.map(({ date, currency, against, value, sampleDate }) => ({ date, coin_symbol: currency, against, value, sample_date: sampleDate, })), ), { cache: false, cacheKeyFn: ({ date, currency, against }) => `${format(date, 'dd-MM-yyyy')}-${currency}-${against ?? 'USD'}`, }, ); private async getCryptoCurrenciesBySymbol( symbols: readonly string[], ): Promise<Array<IGetCryptoCurrenciesBySymbolResult | undefined>> { const currencies = await getCryptoCurrenciesBySymbol.run( { currencySymbols: symbols, }, this.dbProvider, ); return symbols.map(symbol => currencies.find(currency => currency.symbol === symbol)); } public getCryptoCurrenciesBySymbolLoader = new DataLoader( (symbols: readonly string[]) => this.getCryptoCurrenciesBySymbol(symbols), { cache: false }, ); public 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); // Fetch rate from CoinGecko const url = new URL( `https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail/chart?id=${coinmarketcapId}&range=${from}~${to}`, ); const res = await rawFetch(url); const rateData = await res?.json(); const ratesObject = rateData?.data?.points; if (!ratesObject || Object.keys(ratesObject).length === 0) { console.error(url.toString()); throw new GraphQLError(`Error retrieving rate of ${currencySymbol} from CoinMarketCap`); } 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); // Add rate to DB await this.addCryptoRateLoader.load({ date, currency: currencySymbol, value: rate, against: this.fiatCurrency, sampleDate, }); return rate; } public getCryptoExchangeRateLoader = new DataLoader< { cryptoCurrency: string; date: Date; against?: Currency }, IGetRateByCurrencyAndDateResult, string >( async keys => { const rates = await Promise.all( keys.map( async ({ cryptoCurrency, date, against = DEFAULT_CRYPTO_FIAT_CONVERSION_CURRENCY }) => { // 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 = DEFAULT_CRYPTO_FIAT_CONVERSION_CURRENCY }) => `${cryptoCurrency}-${format(date, 'dd-MM-yyyy')}-${against}`, cache: false, }, ); }