Browse Source

Merge branch 'main' into task/remove-deprecated-attributes-from-portfolio-position-interface

pull/6950/head
Thomas Kaul 3 months ago
committed by GitHub
parent
commit
ef07b8ded8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      CHANGELOG.md
  2. 6
      DEVELOPMENT.md
  3. 10
      apps/api/src/app/auth/auth.module.ts
  4. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  5. 3
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  6. 4
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  7. 2
      apps/api/src/app/logo/logo.module.ts
  8. 22
      apps/api/src/app/logo/logo.service.ts
  9. 272
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  10. 4
      apps/api/src/main.ts
  11. 52
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  12. 3
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts
  13. 13
      apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts
  14. 43
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  15. 2
      apps/api/src/services/data-provider/data-provider.module.ts
  16. 43
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  17. 175
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  18. 12
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  19. 4
      apps/api/src/services/data-provider/manual/manual.service.ts
  20. 13
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  21. 9
      apps/api/src/services/fetch/fetch.module.ts
  22. 63
      apps/api/src/services/fetch/fetch.service.ts
  23. 2
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  24. 66
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  25. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  26. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  27. 10
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  28. 12
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  29. 3
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  30. 12
      apps/client/src/app/components/user-account-access/user-account-access.html
  31. 12
      apps/client/src/app/core/auth.guard.ts
  32. 16
      apps/client/src/app/core/auth.interceptor.ts
  33. 45
      apps/client/src/app/core/http-response.interceptor.ts
  34. 24
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  35. 10
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  36. 12
      apps/client/src/app/pages/accounts/accounts-page.html
  37. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  38. 12
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  39. 7
      apps/client/src/assets/oss-friends.json
  40. 11
      apps/client/src/styles.scss
  41. 9
      libs/ui/src/lib/fab/fab.component.html
  42. 14
      libs/ui/src/lib/fab/fab.component.scss
  43. 21
      libs/ui/src/lib/fab/fab.component.ts
  44. 1
      libs/ui/src/lib/fab/index.ts
  45. 6
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  46. 19
      package-lock.json
  47. 5
      package.json

12
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

6
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

10
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`
);

2
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,

3
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
);

4
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
);
}

2
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
],

22
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) => {

272
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<ReturnType<typeof userService.user>>);
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');
});
});
});

4
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>(ConfigService);
let customLogLevels: LogLevel[];

52
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 {

3
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,

13
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];

43
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 (

2
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,

43
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 }) => {

175
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(

12
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(),

4
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<number> {
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')

13
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) {

9
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 {}

63
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<Response> {
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;
}
}
}

2
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]

66
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<number> {
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<number> {
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<number> {
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<number> {
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) {

3
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,

12
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -332,15 +332,5 @@
</div>
</div>
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createAssetProfileDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
<gf-fab [queryParams]="{ createAssetProfileDialog: true }" />
</div>

10
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() {

12
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -22,15 +22,5 @@
</div>
</div>
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) {
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createWatchlistItemDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
<gf-fab [queryParams]="{ createWatchlistItemDialog: true }" />
}

3
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,

12
apps/client/src/app/components/user-account-access/user-account-access.html

@ -69,16 +69,6 @@
(accessToUpdate)="onUpdateAccess($event)"
/>
@if (hasPermissionToCreateAccess) {
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
<gf-fab [queryParams]="{ createDialog: true }" />
}
</div>

12
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;

16
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<any>,
public intercept<T>(
req: HttpRequest<T>,
next: HttpHandler
): Observable<HttpEvent<any>> {
): Observable<HttpEvent<T>> {
let request = req;
if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) {

45
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<TextOnlySnackBar>;
private readonly info: InfoItem;
private snackBarRef: MatSnackBarRef<TextOnlySnackBar> | 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<any>,
public intercept<T>(
request: HttpRequest<T>,
next: HttpHandler
): Observable<HttpEvent<any>> {
): Observable<HttpEvent<T>> {
return next.handle(request).pipe(
tap((event: HttpEvent<any>) => {
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 {

24
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<FileList>();
public readonly filesDropped = output<FileList>();
@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);
}
}
}

10
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() {

12
apps/client/src/app/pages/accounts/accounts-page.html

@ -26,16 +26,6 @@
hasPermissionToCreateAccount &&
!user.settings.isRestrictedView
) {
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
<gf-fab [queryParams]="{ createDialog: true }" />
}
</div>

11
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() {

12
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -43,16 +43,6 @@
hasPermissionToCreateActivity &&
!user.settings.isRestrictedView
) {
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
<gf-fab [queryParams]="{ createDialog: true }" />
}
</div>

7
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.",

11
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));

9
libs/ui/src/lib/fab/fab.component.html

@ -0,0 +1,9 @@
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="queryParams()"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>

14
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);
}
}

21
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<Params>();
public constructor() {
addIcons({ addOutline });
}
}

1
libs/ui/src/lib/fab/index.ts

@ -0,0 +1 @@
export * from './fab.component';

6
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;

19
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",

5
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": {

Loading…
Cancel
Save