diff --git a/CHANGELOG.md b/CHANGELOG.md index d2462d8cc..93bf32fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response + +## 3.6.0 - 2026-05-28 + +### Added + +- Added `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variable support to outbound HTTP requests +- Added the `FetchService` to centralize outbound HTTP requests + +### Changed + +- Extracted the floating action buttons (FAB) to a reusable component - Upgraded `nestjs` from version `11.1.19` to `11.1.21` +- Upgraded `yahoo-finance2` from version `3.14.0` to `3.14.2` ## 3.5.0 - 2026-05-24 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6ea0b5e40..5b1b36afb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,6 +84,12 @@ https://ghostfol.io/development/storybook 1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"` +### NestJS + +#### Upgrade (minor versions) + +1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@nestjs.*/"` + ### Nx #### Upgrade diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 9fc5d0925..f55093bbf 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -5,6 +5,8 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -23,6 +25,7 @@ import { OidcStrategy } from './oidc.strategy'; controllers: [AuthController], imports: [ ConfigurationModule, + FetchModule, JwtModule.register({ secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '180 days' } @@ -40,11 +43,12 @@ import { OidcStrategy } from './oidc.strategy'; GoogleStrategy, JwtStrategy, { - inject: [AuthService, ConfigurationService], + inject: [AuthService, ConfigurationService, FetchService], provide: OidcStrategy, useFactory: async ( authService: AuthService, - configurationService: ConfigurationService + configurationService: ConfigurationService, + fetchService: FetchService ) => { const isOidcEnabled = configurationService.get( 'ENABLE_FEATURE_AUTH_OIDC' @@ -81,7 +85,7 @@ import { OidcStrategy } from './oidc.strategy'; } else { // Fetch OIDC configuration from discovery endpoint try { - const response = await fetch( + const response = await fetchService.fetch( `${issuer}/.well-known/openid-configuration` ); diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts index 01691bcf4..484f30ee3 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts @@ -12,6 +12,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -27,6 +28,7 @@ import { GhostfolioService } from './ghostfolio.service'; imports: [ CryptocurrencyModule, DataProviderModule, + FetchModule, MarketDataModule, PrismaModule, PropertyModule, diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index d088bf3ac..3f91dbecc 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -8,6 +8,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { @@ -36,6 +37,7 @@ export class GhostfolioService { public constructor( private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService ) {} @@ -355,6 +357,7 @@ export class GhostfolioService { private getDataProviderInfo(): DataProviderInfo { const ghostfolioDataProviderService = new GhostfolioDataProviderService( this.configurationService, + this.fetchService, this.propertyService ); diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 0dae82d2c..f6857283b 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -120,10 +120,10 @@ export class MarketDataController { if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { throw new HttpException( - assetProfile.userId + assetProfile?.userId ? getReasonPhrase(StatusCodes.NOT_FOUND) : getReasonPhrase(StatusCodes.FORBIDDEN), - assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN ); } diff --git a/apps/api/src/app/logo/logo.module.ts b/apps/api/src/app/logo/logo.module.ts index 1f59df1c8..8eede126a 100644 --- a/apps/api/src/app/logo/logo.module.ts +++ b/apps/api/src/app/logo/logo.module.ts @@ -1,5 +1,6 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; @@ -11,6 +12,7 @@ import { LogoService } from './logo.service'; controllers: [LogoController], imports: [ ConfigurationModule, + FetchModule, SymbolProfileModule, TransformDataSourceInRequestModule ], diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts index ba1acdd29..551d62438 100644 --- a/apps/api/src/app/logo/logo.service.ts +++ b/apps/api/src/app/logo/logo.service.ts @@ -1,4 +1,5 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; @@ -10,6 +11,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; export class LogoService { public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -43,15 +45,17 @@ export class LogoService { } private async getBuffer(aUrl: string) { - const blob = await fetch( - `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, - { - headers: { 'User-Agent': 'request' }, - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - } - ).then((res) => res.blob()); + const blob = await this.fetchService + .fetch( + `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, + { + headers: { 'User-Agent': 'request' }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ) + .then((res) => res.blob()); return { buffer: await blob.arrayBuffer().then((arrayBuffer) => { diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts new file mode 100644 index 000000000..da846c45d --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -0,0 +1,272 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Account, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { randomUUID } from 'node:crypto'; + +import { PortfolioService } from './portfolio.service'; + +describe('PortfolioService', () => { + let accountService: AccountService; + let activitiesService: ActivitiesService; + let configurationService: ConfigurationService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let impersonationService: ImpersonationService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioService: PortfolioService; + let symbolProfileService: SymbolProfileService; + let userService: UserService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + null + ); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountService = new AccountService( + null, + null, + exchangeRateDataService, + null + ); + + activitiesService = new ActivitiesService( + null, + accountService, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null + ); + + impersonationService = new ImpersonationService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + null, + exchangeRateDataService, + null, + null + ); + + symbolProfileService = new SymbolProfileService(null); + + userService = new UserService( + null, + null, + null, + null, + null, + null, + null, + null + ); + + portfolioService = new PortfolioService( + null, + accountService, + activitiesService, + null, + portfolioCalculatorFactory, + dataProviderService, + exchangeRateDataService, + null, + impersonationService, + null, + null, + symbolProfileService, + userService + ); + }); + + describe('getCashSymbolProfiles', () => { + it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + const cashDetails: CashDetails = { + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: randomUUID(), + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }; + + const assetProfiles = ( + portfolioService as unknown as { + getCashSymbolProfiles: ( + aCashDetails: CashDetails + ) => { dataSource: DataSource; symbol: string }[]; + } + ).getCashSymbolProfiles(cashDetails); + + expect(assetProfiles).toHaveLength(1); + expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); + expect(assetProfiles[0].symbol).toBe('USD'); + }); + }); + + describe('getDetails', () => { + it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { + const accountId = randomUUID(); + + const cashAccount: Account = { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: accountId, + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + }; + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [cashAccount], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [], count: 0 }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest + .spyOn(symbolProfileService, 'getSymbolProfiles') + .mockResolvedValue([]); + + jest.spyOn(userService, 'user').mockResolvedValue({ + accessesGet: [], + accounts: [], + activityCount: 0, + dataProviderGhostfolioDailyRequests: 0, + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'CHF' + } + } + } as unknown as Awaited>); + + const usdPosition = { + activitiesCount: 1, + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dateOfFirstActivity: '2024-01-01', + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1820), + marketPrice: 1, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceWithCurrencyEffectMap: {}, + quantity: new Big(2000), + symbol: 'USD', + tags: [], + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + valueInBaseCurrency: new Big(1820) + }; + + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue({ + getSnapshot: jest.fn().mockResolvedValue({ + activitiesCount: 1, + createdAt: parseDate('2024-01-01'), + currentValueInBaseCurrency: new Big(1820), + errors: [], + hasErrors: false, + historicalData: [], + positions: [usdPosition], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(1820), + totalInvestmentWithCurrencyEffect: new Big(1820), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }) + } as unknown as ReturnType< + typeof portfolioCalculatorFactory.createCalculator + >); + + jest + .spyOn( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: () => Promise<{ + accounts: object; + platforms: object; + }>; + }, + 'getValueOfAccountsAndPlatforms' + ) + .mockResolvedValue({ accounts: {}, platforms: {} }); + + const { holdings } = await portfolioService.getDetails({ + filters: [], + impersonationId: userDummyData.id, + userId: userDummyData.id + }); + + expect(holdings['USD']).toBeDefined(); + expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); + expect(holdings['USD'].assetProfile.symbol).toBe('USD'); + }); + }); +}); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f08a09a83..94e389f6a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -18,11 +18,15 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; async function bootstrap() { + // Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests + setGlobalDispatcher(new EnvHttpProxyAgent()); + const configApp = await NestFactory.create(AppModule); const configService = configApp.get(ConfigService); let customLogLevels: LogLevel[]; diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index d5ed69d06..b01ba177b 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -7,6 +7,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { @@ -32,7 +33,8 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { private headers: HeadersInit = {}; public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public onModuleInit() { @@ -67,12 +69,14 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { }; try { - const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, { - headers: this.headers, - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - }).then((res) => res.json()); + const { name } = await this.fetchService + .fetch(`${this.apiUrl}/coins/${symbol}`, { + headers: this.headers, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }) + .then((res) => res.json()); response.name = name; } catch (error) { @@ -118,13 +122,15 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { vs_currency: DEFAULT_CURRENCY.toLowerCase() }); - const { error, prices, status } = await fetch( - `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, - { - headers: this.headers, - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const { error, prices, status } = await this.fetchService + .fetch( + `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, + { + headers: this.headers, + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); if (error?.status) { throw new Error(error.status.error_message); @@ -181,13 +187,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { vs_currencies: DEFAULT_CURRENCY.toLowerCase() }); - const quotes = await fetch( - `${this.apiUrl}/simple/price?${queryParams.toString()}`, - { + const quotes = await this.fetchService + .fetch(`${this.apiUrl}/simple/price?${queryParams.toString()}`, { headers: this.headers, signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); for (const symbol in quotes) { response[symbol] = { @@ -230,13 +235,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { query }); - const { coins } = await fetch( - `${this.apiUrl}/search?${queryParams.toString()}`, - { + const { coins } = await this.fetchService + .fetch(`${this.apiUrl}/search?${queryParams.toString()}`, { headers: this.headers, signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); items = coins.map(({ id: symbol, name }) => { return { diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts index cadf8cf1d..ecad9a673 100644 --- a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts @@ -3,6 +3,7 @@ import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cr import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service'; import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { Module } from '@nestjs/common'; @@ -16,7 +17,7 @@ import { DataEnhancerService } from './data-enhancer.service'; YahooFinanceDataEnhancerService, 'DataEnhancers' ], - imports: [ConfigurationModule, CryptocurrencyModule], + imports: [ConfigurationModule, CryptocurrencyModule, FetchModule], providers: [ DataEnhancerService, OpenFigiDataEnhancerService, diff --git a/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts b/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts index bb9d0606c..1f5bb74b4 100644 --- a/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts @@ -1,5 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { parseSymbol } from '@ghostfolio/common/helper'; import { Injectable } from '@nestjs/common'; @@ -10,7 +11,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://api.openfigi.com'; public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public async enhance({ @@ -42,9 +44,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface { this.configurationService.get('API_KEY_OPEN_FIGI'); } - const mappings = (await fetch( - `${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, - { + const mappings = (await this.fetchService + .fetch(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, { body: JSON.stringify([ { exchCode: exchange, idType: 'TICKER', idValue: ticker } ]), @@ -54,8 +55,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface { }, method: 'POST', signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json())) as any[]; + }) + .then((res) => res.json())) as any[]; if (mappings?.length === 1 && mappings[0].data?.length === 1) { const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0]; diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 1e297b93b..54627f312 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -1,5 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { Holding } from '@ghostfolio/common/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; @@ -23,7 +24,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { }; public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public async enhance({ @@ -60,12 +62,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return response; } - const profile = await fetch( - `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ) + const profile = await this.fetchService + .fetch( + `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) .then((res) => res.json()) .catch(() => { return {}; @@ -83,12 +86,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { response.isin = isin; } - const holdings = await fetch( - `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ) + const holdings = await this.fetchService + .fetch( + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) .then((res) => res.json()) .catch(() => { return {}; @@ -182,12 +186,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { requestTimeout: number; symbol: string; }) { - return fetch( - `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ) + return this.fetchService + .fetch( + `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) .then((res) => res.json()) .then((jsonRes) => { if ( diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts index 71b54f01e..2c6e9fce1 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -10,6 +10,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service'; ConfigurationModule, CryptocurrencyModule, DataEnhancerModule, + FetchModule, MarketDataModule, PrismaModule, PropertyModule, diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index 8c718108c..3fa38842b 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -7,6 +7,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DEFAULT_CURRENCY, @@ -41,6 +42,7 @@ export class EodHistoricalDataService public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -111,12 +113,11 @@ export class EodHistoricalDataService [date: string]: DataProviderHistoricalResponse; } = {}; - const historicalResult = await fetch( - `${this.URL}/div/${symbol}?${queryParams.toString()}`, - { + const historicalResult = await this.fetchService + .fetch(`${this.URL}/div/${symbol}?${queryParams.toString()}`, { signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); for (const { date, value } of historicalResult) { response[date] = { @@ -158,12 +159,11 @@ export class EodHistoricalDataService to: format(to, DATE_FORMAT) }); - const response = await fetch( - `${this.URL}/eod/${symbol}?${queryParams.toString()}`, - { + const response = await this.fetchService + .fetch(`${this.URL}/eod/${symbol}?${queryParams.toString()}`, { signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); return response.reduce( (result, { adjusted_close, date }) => { @@ -223,12 +223,14 @@ export class EodHistoricalDataService s: eodHistoricalDataSymbols.join(',') }); - const realTimeResponse = await fetch( - `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const realTimeResponse = await this.fetchService + .fetch( + `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); const quotes: { close: number; @@ -430,12 +432,11 @@ export class EodHistoricalDataService api_token: this.apiKey }); - const response = await fetch( - `${this.URL}/search/${query}?${queryParams.toString()}`, - { + const response = await this.fetchService + .fetch(`${this.URL}/search/${query}?${queryParams.toString()}`, { signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); searchResult = response.map( ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index d9a43fc50..fa36a0d17 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -9,6 +9,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DEFAULT_CURRENCY, @@ -59,6 +60,7 @@ export class FinancialModelingPrepService public constructor( private readonly configurationService: ConfigurationService, private readonly cryptocurrencyService: CryptocurrencyService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService ) {} @@ -96,12 +98,14 @@ export class FinancialModelingPrepService apikey: this.apiKey }); - const [quote] = await fetch( - `${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const [quote] = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); response.assetClass = AssetClass.LIQUIDITY; response.assetSubClass = AssetSubClass.CRYPTOCURRENCY; @@ -115,12 +119,14 @@ export class FinancialModelingPrepService apikey: this.apiKey }); - const [assetProfile] = await fetch( - `${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const [assetProfile] = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); if (!assetProfile) { throw new AssetProfileDelistedError( @@ -143,12 +149,14 @@ export class FinancialModelingPrepService apikey: this.apiKey }); - const etfCountryWeightings = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const etfCountryWeightings = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); response.countries = etfCountryWeightings .filter(({ country: countryName }) => { @@ -174,12 +182,14 @@ export class FinancialModelingPrepService }; }); - const etfHoldings = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const etfHoldings = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); const sortedTopHoldings = etfHoldings .sort((a, b) => { @@ -193,23 +203,27 @@ export class FinancialModelingPrepService } ); - const [etfInformation] = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const [etfInformation] = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); if (etfInformation?.website) { response.url = etfInformation.website; } - const etfSectorWeightings = await fetch( - `${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const etfSectorWeightings = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); response.sectors = etfSectorWeightings.map( ({ sector, weightPercentage }) => { @@ -286,12 +300,14 @@ export class FinancialModelingPrepService [date: string]: DataProviderHistoricalResponse; } = {}; - const dividends = await fetch( - `${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const dividends = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); dividends .filter(({ date }) => { @@ -354,12 +370,14 @@ export class FinancialModelingPrepService to: format(currentTo, DATE_FORMAT) }); - const historical = await fetch( - `${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const historical = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); for (const { close, date } of historical) { if ( @@ -422,14 +440,17 @@ export class FinancialModelingPrepService symbolTarget: { in: symbols } } }), - fetch( - `${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then( - (res) => res.json() as unknown as { price: number; symbol: string }[] - ) + this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then( + (res) => + res.json() as unknown as { price: number; symbol: string }[] + ) ]); for (const { currency, symbolTarget } of assetProfileResolutions) { @@ -525,12 +546,14 @@ export class FinancialModelingPrepService isin: query.toUpperCase() }); - const result = await fetch( - `${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()); + const result = await this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()); await Promise.all( result.map(({ symbol }) => { @@ -558,18 +581,22 @@ export class FinancialModelingPrepService }); const [nameResults, symbolResults] = await Promise.all([ - fetch( - `${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()), - fetch( - `${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`, - { - signal: AbortSignal.timeout(requestTimeout) - } - ).then((res) => res.json()) + this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()), + this.fetchService + .fetch( + `${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()) ]); const result = uniqBy( diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 2b49e89c2..2f2601d5d 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -8,6 +8,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { HEADER_KEY_TOKEN, @@ -38,6 +39,7 @@ export class GhostfolioService implements DataProviderInterface { public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly propertyService: PropertyService ) {} @@ -52,7 +54,7 @@ export class GhostfolioService implements DataProviderInterface { let assetProfile: DataProviderGhostfolioAssetProfileResponse; try { - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`, { headers: await this.getRequestHeaders(), @@ -122,7 +124,7 @@ export class GhostfolioService implements DataProviderInterface { to: format(to, DATE_FORMAT) }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), @@ -174,7 +176,7 @@ export class GhostfolioService implements DataProviderInterface { to: format(to, DATE_FORMAT) }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), @@ -245,7 +247,7 @@ export class GhostfolioService implements DataProviderInterface { symbols: symbols.join(',') }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), @@ -302,7 +304,7 @@ export class GhostfolioService implements DataProviderInterface { query }); - const response = await fetch( + const response = await this.fetchService.fetch( `${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`, { headers: await this.getRequestHeaders(), diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index 51e65e631..11e0aae6a 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -8,6 +8,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { @@ -32,6 +33,7 @@ import { addDays, format, isBefore } from 'date-fns'; export class ManualService implements DataProviderInterface { public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -292,7 +294,7 @@ export class ManualService implements DataProviderInterface { }): Promise { let locale = scraperConfiguration.locale; - const response = await fetch(scraperConfiguration.url, { + const response = await this.fetchService.fetch(scraperConfiguration.url, { headers: scraperConfiguration.headers as HeadersInit, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index d6bc8d0e4..22896cccc 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -7,6 +7,7 @@ import { GetQuotesParams, GetSearchParams } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { ghostfolioFearAndGreedIndexSymbol, ghostfolioFearAndGreedIndexSymbolStocks @@ -26,7 +27,8 @@ import { format } from 'date-fns'; @Injectable() export class RapidApiService implements DataProviderInterface { public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public canHandle() { @@ -142,9 +144,8 @@ export class RapidApiService implements DataProviderInterface { oneYearAgo: { value: number; valueText: string }; }> { try { - const { fgi } = await fetch( - `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, - { + const { fgi } = await this.fetchService + .fetch(`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, { headers: { useQueryString: 'true', 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', @@ -153,8 +154,8 @@ export class RapidApiService implements DataProviderInterface { signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) - } - ).then((res) => res.json()); + }) + .then((res) => res.json()); return fgi; } catch (error) { diff --git a/apps/api/src/services/fetch/fetch.module.ts b/apps/api/src/services/fetch/fetch.module.ts new file mode 100644 index 000000000..f98f2f45c --- /dev/null +++ b/apps/api/src/services/fetch/fetch.module.ts @@ -0,0 +1,9 @@ +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [FetchService], + providers: [FetchService] +}) +export class FetchModule {} diff --git a/apps/api/src/services/fetch/fetch.service.ts b/apps/api/src/services/fetch/fetch.service.ts new file mode 100644 index 000000000..b3bd022d9 --- /dev/null +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -0,0 +1,63 @@ +import { redactPaths } from '@ghostfolio/api/helper/object.helper'; + +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class FetchService { + private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; + + public async fetch( + input: RequestInfo | URL, + init?: RequestInit + ): Promise { + const method = ( + init?.method ?? + (input instanceof Request ? input.method : undefined) ?? + 'GET' + ).toUpperCase(); + + const url = input instanceof Request ? input.url : input.toString(); + const urlRedacted = this.redactUrl(url); + + Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); + + try { + return await globalThis.fetch(input, init); + } catch (error) { + if (error instanceof Error) { + Logger.error( + `${method} ${urlRedacted} failed: [${error.name}] ${error.message}`, + 'FetchService' + ); + } else { + Logger.error( + `${method} ${urlRedacted} failed: ${String(error)}`, + 'FetchService' + ); + } + + throw error; + } + } + + private redactUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + + const redacted = redactPaths({ + object: Object.fromEntries(url.searchParams), + paths: FetchService.REDACTED_QUERY_PARAM_NAMES + }); + + for (const [key, value] of Object.entries(redacted)) { + if (value === null) { + url.searchParams.set(key, '*******'); + } + } + + return url.toString(); + } catch { + return rawUrl; + } + } +} diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts index 60b963c69..d6f6d5ccd 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts @@ -1,4 +1,5 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config'; @@ -29,6 +30,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service'; name: STATISTICS_GATHERING_QUEUE }), ConfigurationModule, + FetchModule, PropertyModule ], providers: [StatisticsGatheringProcessor, StatisticsGatheringService] diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts index 1312d49ea..a523ef4f2 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts @@ -1,4 +1,5 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME, @@ -28,6 +29,7 @@ import { format, subDays } from 'date-fns'; export class StatisticsGatheringProcessor { public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly propertyService: PropertyService ) {} @@ -126,15 +128,14 @@ export class StatisticsGatheringProcessor { private async countDockerHubPulls(): Promise { try { - const { pull_count } = (await fetch( - 'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', - { + const { pull_count } = (await this.fetchService + .fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', { headers: { 'User-Agent': 'request' }, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) - } - ).then((res) => res.json())) as { pull_count: number }; + }) + .then((res) => res.json())) as { pull_count: number }; return pull_count; } catch (error) { @@ -146,11 +147,13 @@ export class StatisticsGatheringProcessor { private async countGitHubContributors(): Promise { try { - const body = await fetch('https://github.com/ghostfolio/ghostfolio', { - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - }).then((res) => res.text()); + const body = await this.fetchService + .fetch('https://github.com/ghostfolio/ghostfolio', { + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }) + .then((res) => res.text()); const $ = cheerio.load(body); @@ -174,15 +177,14 @@ export class StatisticsGatheringProcessor { private async countGitHubStargazers(): Promise { try { - const { stargazers_count } = (await fetch( - 'https://api.github.com/repos/ghostfolio/ghostfolio', - { + const { stargazers_count } = (await this.fetchService + .fetch('https://api.github.com/repos/ghostfolio/ghostfolio', { headers: { 'User-Agent': 'request' }, signal: AbortSignal.timeout( this.configurationService.get('REQUEST_TIMEOUT') ) - } - ).then((res) => res.json())) as { stargazers_count: number }; + }) + .then((res) => res.json())) as { stargazers_count: number }; return stargazers_count; } catch (error) { @@ -194,22 +196,24 @@ export class StatisticsGatheringProcessor { private async getUptime(monitorId: string): Promise { try { - const { data } = await fetch( - `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( - subDays(new Date(), 90), - DATE_FORMAT - )}&to${format(new Date(), DATE_FORMAT)}`, - { - headers: { - [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( - 'API_KEY_BETTER_UPTIME' - )}` - }, - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - } - ).then((res) => res.json()); + const { data } = await this.fetchService + .fetch( + `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( + subDays(new Date(), 90), + DATE_FORMAT + )}&to${format(new Date(), DATE_FORMAT)}`, + { + headers: { + [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( + 'API_KEY_BETTER_UPTIME' + )}` + }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ) + .then((res) => res.json()); return data.attributes.availability / 100; } catch (error) { diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 28b7297d2..805adf89d 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -18,6 +18,7 @@ import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market- import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { AdminService, DataService } from '@ghostfolio/ui/services'; @@ -80,10 +81,10 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'has-fab' }, imports: [ CommonModule, GfActivitiesFilterComponent, + GfFabComponent, GfPremiumIndicatorComponent, GfSymbolPipe, GfValueComponent, diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index 14d12627d..63d425513 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -332,15 +332,5 @@ -
- - - -
+ diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts index 7deace7de..22d829daa 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -8,6 +8,7 @@ import { } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; @@ -22,12 +23,8 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; @@ -37,9 +34,8 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfBenchmarkComponent, + GfFabComponent, GfPremiumIndicatorComponent, - IonIcon, - MatButtonModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -108,8 +104,6 @@ export class GfHomeWatchlistComponent implements OnInit { this.changeDetectorRef.markForCheck(); } }); - - addIcons({ addOutline }); } public ngOnInit() { diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html index c7c9a9c4b..e2865b9cf 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.html +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -22,15 +22,5 @@ @if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { -
- - - -
+ } diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index 985dba2cb..eef50cee3 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -4,6 +4,7 @@ import { CreateAccessDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; @@ -42,9 +43,9 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'has-fab' }, imports: [ GfAccessTableComponent, + GfFabComponent, GfPremiumIndicatorComponent, IonIcon, MatButtonModule, diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html index 412a2f8d2..62b1648bb 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.html +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -69,16 +69,6 @@ (accessToUpdate)="onUpdateAccess($event)" /> @if (hasPermissionToCreateAccess) { -
- - - -
+ } diff --git a/apps/client/src/app/core/auth.guard.ts b/apps/client/src/app/core/auth.guard.ts index 3292f0ff7..6ac3417db 100644 --- a/apps/client/src/app/core/auth.guard.ts +++ b/apps/client/src/app/core/auth.guard.ts @@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router, @@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class AuthGuard { - public constructor( - private dataService: DataService, - private router: Router, - private settingsStorageService: SettingsStorageService, - private userService: UserService - ) {} + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly settingsStorageService = inject(SettingsStorageService); + private readonly userService = inject(UserService); canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { const utmSource = route.queryParams?.utm_source; diff --git a/apps/client/src/app/core/auth.interceptor.ts b/apps/client/src/app/core/auth.interceptor.ts index 7491cecf1..9c06a11d5 100644 --- a/apps/client/src/app/core/auth.interceptor.ts +++ b/apps/client/src/app/core/auth.interceptor.ts @@ -13,20 +13,20 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - public constructor( - private impersonationStorageService: ImpersonationStorageService, - private tokenStorageService: TokenStorageService - ) {} + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly tokenStorageService = inject(TokenStorageService); - public intercept( - req: HttpRequest, + public intercept( + req: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { let request = req; if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 315e9d64e..17927a924 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -12,7 +12,7 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { MatSnackBar, MatSnackBarRef, @@ -22,31 +22,28 @@ import { Router } from '@angular/router'; import { StatusCodes } from 'http-status-codes'; import ms from 'ms'; import { Observable, throwError } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; @Injectable() export class HttpResponseInterceptor implements HttpInterceptor { - public info: InfoItem; - public snackBarRef: MatSnackBarRef; + private readonly info: InfoItem; + private snackBarRef: MatSnackBarRef | undefined; - public constructor( - private dataService: DataService, - private router: Router, - private snackBar: MatSnackBar, - private userService: UserService, - private webAuthnService: WebAuthnService - ) { + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + private readonly webAuthnService = inject(WebAuthnService); + + public constructor() { this.info = this.dataService.fetchInfo(); } - public intercept( - request: HttpRequest, + public intercept( + request: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { return next.handle(request).pipe( - tap((event: HttpEvent) => { - return event; - }), catchError((error: HttpErrorResponse) => { if (error.status === StatusCodes.FORBIDDEN) { if (!this.snackBarRef) { @@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); } else if ( - !error.url.includes(internalRoutes.auth.routerLink.join('')) + !error.url?.includes(internalRoutes.auth.routerLink.join('')) ) { this.snackBarRef = this.snackBar.open( $localize`This action is not allowed.`, @@ -72,11 +69,11 @@ export class HttpResponseInterceptor implements HttpInterceptor { ); } - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { this.router.navigate(publicRoutes.pricing.routerLink); }); } @@ -92,11 +89,11 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { window.location.reload(); }); } @@ -106,12 +103,12 @@ export class HttpResponseInterceptor implements HttpInterceptor { $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` ); - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); } } else if (error.status === StatusCodes.UNAUTHORIZED) { - if (!error.url.includes('/data-providers/ghostfolio/status')) { + if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (this.webAuthnService.isEnabled()) { this.router.navigate(internalRoutes.webauthn.routerLink); } else { diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts index a7e628bc9..b46357005 100644 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ b/apps/client/src/app/directives/file-drop/file-drop.directive.ts @@ -1,28 +1,34 @@ -import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; +import { Directive, output } from '@angular/core'; @Directive({ + host: { + '(dragenter)': 'onDragEnter($event)', + '(dragover)': 'onDragOver($event)', + '(drop)': 'onDrop($event)' + }, selector: '[gfFileDrop]' }) export class GfFileDropDirective { - @Output() filesDropped = new EventEmitter(); + public readonly filesDropped = output(); - @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { + public onDragEnter(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { + public onDragOver(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('drop', ['$event']) onDrop(event: DragEvent) { + public onDrop(event: DragEvent) { event.preventDefault(); event.stopPropagation(); - // Prevent the browser's default behavior for handling the file drop - event.dataTransfer.dropEffect = 'copy'; - - this.filesDropped.emit(event.dataTransfer.files); + if (event.dataTransfer) { + // Prevent the browser's default behavior for handling the file drop + event.dataTransfer.dropEffect = 'copy'; + this.filesDropped.emit(event.dataTransfer.files); + } } } diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 08513ef3e..cca1eda03 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -10,6 +10,7 @@ import { import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { DataService } from '@ghostfolio/ui/services'; @@ -20,12 +21,9 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { Account as AccountModel } from '@prisma/client'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; import { EMPTY, Subscription } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -36,8 +34,8 @@ import { TransferBalanceDialogParams } from './transfer-balance/interfaces/inter import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component'; @Component({ - host: { class: 'has-fab page' }, - imports: [GfAccountsTableComponent, MatButtonModule, RouterModule], + host: { class: 'page' }, + imports: [GfAccountsTableComponent, GfFabComponent, RouterModule], selector: 'gf-accounts-page', styleUrls: ['./accounts-page.scss'], templateUrl: './accounts-page.html' @@ -90,8 +88,6 @@ export class GfAccountsPageComponent implements OnInit { this.openTransferBalanceDialog(); } }); - - addIcons({ addOutline }); } public ngOnInit() { diff --git a/apps/client/src/app/pages/accounts/accounts-page.html b/apps/client/src/app/pages/accounts/accounts-page.html index 3d9d7ee5c..1bdedbbb9 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.html +++ b/apps/client/src/app/pages/accounts/accounts-page.html @@ -26,16 +26,6 @@ hasPermissionToCreateAccount && !user.settings.isRestrictedView ) { -
- - - -
+ } diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index e43af52c9..41ff570c2 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -12,6 +12,7 @@ import { import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DateRange } from '@ghostfolio/common/types'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { DataService } from '@ghostfolio/ui/services'; import { @@ -21,17 +22,13 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { PageEvent } from '@angular/material/paginator'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; import { format, parseISO } from 'date-fns'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subscription } from 'rxjs'; @@ -41,11 +38,9 @@ import { GfImportActivitiesDialogComponent } from './import-activities-dialog/im import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; @Component({ - host: { class: 'has-fab' }, imports: [ GfActivitiesTableComponent, - IonIcon, - MatButtonModule, + GfFabComponent, MatSnackBarModule, RouterModule ], @@ -107,8 +102,6 @@ export class GfActivitiesPageComponent implements OnInit { } } }); - - addIcons({ addOutline }); } public ngOnInit() { diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index 2a72dcfd2..f06947988 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -43,16 +43,6 @@ hasPermissionToCreateActivity && !user.settings.isRestrictedView ) { -
- - - -
+ } diff --git a/apps/client/src/assets/oss-friends.json b/apps/client/src/assets/oss-friends.json index 7b58079ab..da409ca2f 100644 --- a/apps/client/src/assets/oss-friends.json +++ b/apps/client/src/assets/oss-friends.json @@ -1,5 +1,5 @@ { - "createdAt": "2026-04-21T00:00:00.000Z", + "createdAt": "2026-05-26T00:00:00.000Z", "data": [ { "name": "Activepieces", @@ -101,6 +101,11 @@ "description": "Simplify working with databases. Build, optimize, and grow your app easily with an intuitive data model, type-safety, automated migrations, connection pooling, caching, and real-time db subscriptions.", "href": "https://www.prisma.io" }, + { + "name": "Rallly", + "description": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.", + "href": "https://rallly.co" + }, { "name": "Requestly", "description": "Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.", diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 1eb5bd2dd..4321622a1 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -360,10 +360,6 @@ ngx-skeleton-loader { text-wrap: balance; } -.has-fab { - padding-bottom: 3rem !important; -} - .has-info-message { // Restrict viewport height of tabbed views when the Live Demo or system announcements banner are displayed .page:has(gf-page-tabs) { @@ -484,13 +480,6 @@ ngx-skeleton-loader { padding-bottom: env(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom); - .fab-container { - bottom: 2rem; - position: fixed; - right: 2rem; - z-index: 999; - } - // Restrict viewport height and layout boundaries only when the page hosts tab navigation &:has(gf-page-tabs) { height: calc(100svh - var(--mat-toolbar-standard-height)); diff --git a/libs/ui/src/lib/fab/fab.component.html b/libs/ui/src/lib/fab/fab.component.html new file mode 100644 index 000000000..021bc5f79 --- /dev/null +++ b/libs/ui/src/lib/fab/fab.component.html @@ -0,0 +1,9 @@ + + + diff --git a/libs/ui/src/lib/fab/fab.component.scss b/libs/ui/src/lib/fab/fab.component.scss new file mode 100644 index 000000000..ab6353981 --- /dev/null +++ b/libs/ui/src/lib/fab/fab.component.scss @@ -0,0 +1,14 @@ +:host { + bottom: calc(constant(safe-area-inset-bottom) + 2rem); + bottom: calc(env(safe-area-inset-bottom) + 2rem); + position: fixed; + right: 2rem; + z-index: 999; +} + +:host-context(gf-page-tabs) { + @media (max-width: 575.98px) { + bottom: calc(constant(safe-area-inset-bottom) + 5rem); + bottom: calc(env(safe-area-inset-bottom) + 5rem); + } +} diff --git a/libs/ui/src/lib/fab/fab.component.ts b/libs/ui/src/lib/fab/fab.component.ts new file mode 100644 index 000000000..20972d5a6 --- /dev/null +++ b/libs/ui/src/lib/fab/fab.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { Params, RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { addOutline } from 'ionicons/icons'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IonIcon, MatButtonModule, RouterModule], + selector: 'gf-fab', + styleUrls: ['./fab.component.scss'], + templateUrl: './fab.component.html' +}) +export class GfFabComponent { + public readonly queryParams = input.required(); + + public constructor() { + addIcons({ addOutline }); + } +} diff --git a/libs/ui/src/lib/fab/index.ts b/libs/ui/src/lib/fab/index.ts new file mode 100644 index 000000000..d03295245 --- /dev/null +++ b/libs/ui/src/lib/fab/index.ts @@ -0,0 +1 @@ +export * from './fab.component'; diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.scss b/libs/ui/src/lib/page-tabs/page-tabs.component.scss index 920b00ae9..0b377e57a 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.scss +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.scss @@ -15,12 +15,6 @@ ); ::ng-deep { - .fab-container { - @media (max-width: 575.98px) { - bottom: 5rem; - } - } - .mat-mdc-tab-nav-panel { padding: 2rem 0; diff --git a/package-lock.json b/package-lock.json index f8e778549..c9abbd50d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.6.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -94,7 +94,8 @@ "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", - "yahoo-finance2": "3.14.0", + "undici": "7.24.4", + "yahoo-finance2": "3.14.2", "zone.js": "0.16.1" }, "devDependencies": { @@ -28576,9 +28577,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", "dev": true, "license": "MIT", "peer": true, @@ -39885,9 +39886,9 @@ } }, "node_modules/yahoo-finance2": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.0.tgz", - "integrity": "sha512-gsT/tqgeizKtMxbIIWFiFyuhM/6MZE4yEyNLmPekr88AX14JL2HWw0/QNMOR081jVtzTjihqDW0zV7IayH1Wcw==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.2.tgz", + "integrity": "sha512-s+F7TWQT7zAtjhfC7rFHEX16Xfq36u3wceysINP7V+esF3mAYyk9slxZU+fEdkxaTuCT0+PnikHdekMX4UPMrg==", "license": "MIT", "dependencies": { "@deno/shim-deno": "~0.18.0", diff --git a/package.json b/package.json index 857313cae..22e39c437 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.6.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -138,7 +138,8 @@ "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", - "yahoo-finance2": "3.14.0", + "undici": "7.24.4", + "yahoo-finance2": "3.14.2", "zone.js": "0.16.1" }, "devDependencies": {