diff --git a/.vscode/launch.json b/.vscode/launch.json index c1f19e7f0..6d36314d2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,12 +18,20 @@ "autoAttachChildProcesses": true, "console": "integratedTerminal", "cwd": "${workspaceFolder}/apps/api", - "envFile": "${workspaceFolder}/.env", + "env": { + "GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env" + }, "name": "Debug API", "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], "program": "${workspaceFolder}/apps/api/src/main.ts", "request": "launch", - "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], + "runtimeArgs": [ + "--nolazy", + "-r", + "ts-node/register", + "-r", + "${workspaceFolder}/tools/load-env.ts" + ], "skipFiles": [ "${workspaceFolder}/node_modules/**/*.js", "/**/*.js" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df96b0e6..80e6f38e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added an automatic refresh every 30 seconds to the users table in the admin control panel + ### Changed +- Centralized the asset profile override logic for manual adjustments - Prevented the deletion of asset profiles that are currently in use - Ensured market data is correctly removed when an asset profile with no remaining activities is deleted +- Refactored the backend logging to use the instance-based `Logger` +- Improved the language localization for Ukrainian (`uk`) + +### Fixed + +- Fixed an issue where the asset profile override (asset class and asset sub class) was not applied to the data enhancers when gathering asset profiles +- Fixed a layout issue in the asset profile dialog of the admin control by truncating long titles + +## 3.7.0 - 2026-06-02 + +### Added + +- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService` + +### Changed + +- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ +- 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 +- Upgraded `Nx` from version `22.7.2` to `22.7.5` + +### Fixed + +- Resolved an issue in the impersonation mode where the values did not match the owner’s currency +- Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_ + +## 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/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 052720176..d44b716c0 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,5 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; @@ -50,7 +51,8 @@ export class AccountController { private readonly apiService: ApiService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService ) {} @Delete(':id') @@ -137,11 +139,14 @@ export class AccountController { ): Promise { const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); + const userId = impersonationUserId || this.request.user.id; + + const { settings } = await this.userService.user({ id: userId }); return this.accountBalanceService.getAccountBalances({ + userId, filters: [{ id, type: 'ACCOUNT' }], - userCurrency: this.request.user.settings.settings.baseCurrency, - userId: impersonationUserId || this.request.user.id + userCurrency: settings.settings.baseCurrency }); } diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index fb89bb2b6..253c7fb1d 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,5 +1,6 @@ import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; @@ -23,7 +24,8 @@ import { AccountService } from './account.service'; ImpersonationModule, PortfolioModule, PrismaModule, - RedactValuesInResponseModule + RedactValuesInResponseModule, + UserModule ], providers: [AccountService] }) diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 8df75c045..1a2c58d1c 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -63,6 +63,8 @@ import { AdminService } from './admin.service'; @Controller('admin') export class AdminController { + private readonly logger = new Logger(AdminController.name); + public constructor( private readonly adminService: AdminService, private readonly apiService: ApiService, @@ -267,7 +269,7 @@ export class AdminController { `Could not parse the market price for ${symbol} (${dataSource})` ); } catch (error) { - Logger.error(error, 'AdminController'); + this.logger.error(error); throw new HttpException(error.message, StatusCodes.BAD_REQUEST); } diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 0bf5c3925..948616d6c 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -14,6 +14,7 @@ import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; import { + applyAssetProfileOverrides, getAssetProfileIdentifier, getCurrencyFromSymbol, isCurrency @@ -29,7 +30,6 @@ import { EnhancedSymbolProfile, Filter } from '@ghostfolio/common/interfaces'; -import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { MarketDataPreset } from '@ghostfolio/common/types'; import { @@ -349,87 +349,61 @@ export class AdminService { } let marketData: AdminMarketDataItem[] = await Promise.all( - assetProfiles.map( - async ({ + assetProfiles.map(async (assetProfile) => { + const { _count, activities, - assetClass, - assetSubClass, comment, - countries, currency, dataSource, id, isActive, isUsedByUsersWithSubscription, - name, - sectors, - symbol, - SymbolProfileOverrides - }) => { - let countriesCount = countries ? Object.keys(countries).length : 0; + symbol + } = assetProfile; - const lastMarketPrice = lastMarketPriceMap.get( - getAssetProfileIdentifier({ dataSource, symbol }) + const { assetClass, assetSubClass, countries, name, sectors } = + applyAssetProfileOverrides( + assetProfile, + assetProfile.SymbolProfileOverrides ); - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - - let sectorsCount = sectors ? Object.keys(sectors).length : 0; - - if (SymbolProfileOverrides) { - assetClass = SymbolProfileOverrides.assetClass ?? assetClass; - assetSubClass = - SymbolProfileOverrides.assetSubClass ?? assetSubClass; - - if ( - (SymbolProfileOverrides.countries as unknown as Prisma.JsonArray) - ?.length > 0 - ) { - countriesCount = ( - SymbolProfileOverrides.countries as unknown as Prisma.JsonArray - ).length; - } + const countriesCount = countries ? Object.keys(countries).length : 0; - name = SymbolProfileOverrides.name ?? name; + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); - if ( - (SymbolProfileOverrides.sectors as unknown as Sector[])?.length > - 0 - ) { - sectorsCount = ( - SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray - ).length; - } - } + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; - return { - assetClass, - assetSubClass, - comment, - countriesCount, - currency, - dataSource, - id, - isActive, - lastMarketPrice, - marketDataItemCount, - name, - sectorsCount, - symbol, - activitiesCount: _count.activities, - date: activities?.[0]?.date, - isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, - watchedByCount: _count.watchedBy - }; - } - ) + const sectorsCount = sectors ? Object.keys(sectors).length : 0; + + return { + assetClass, + assetSubClass, + comment, + countriesCount, + currency, + dataSource, + id, + isActive, + lastMarketPrice, + marketDataItemCount, + name, + sectorsCount, + symbol, + activitiesCount: _count.activities, + date: activities?.[0]?.date, + isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, + watchedByCount: _count.watchedBy + }; + }) ); if (presetId) { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 9fc5d0925..1d6990307 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,12 +43,15 @@ 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 logger = new Logger('OidcStrategy'); + const isOidcEnabled = configurationService.get( 'ENABLE_FEATURE_AUTH_OIDC' ); @@ -81,7 +87,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` ); @@ -97,7 +103,7 @@ import { OidcStrategy } from './oidc.strategy'; tokenURL = manualTokenUrl || config.token_endpoint; userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; } catch (error) { - Logger.error(error, 'OidcStrategy'); + logger.error(error); throw new Error('Failed to fetch OIDC configuration from issuer'); } } diff --git a/apps/api/src/app/auth/google.strategy.ts b/apps/api/src/app/auth/google.strategy.ts index 3e4b4ca0d..53720c383 100644 --- a/apps/api/src/app/auth/google.strategy.ts +++ b/apps/api/src/app/auth/google.strategy.ts @@ -10,6 +10,8 @@ import { AuthService } from './auth.service'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + private readonly logger = new Logger(GoogleStrategy.name); + public constructor( private readonly authService: AuthService, configurationService: ConfigurationService @@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { done(null, { jwt }); } catch (error) { - Logger.error(error, 'GoogleStrategy'); + this.logger.error(error); done(error, false); } } diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 96b284121..661f2a821 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -15,6 +15,8 @@ import { OidcStateStore } from './oidc-state.store'; @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { + private readonly logger = new Logger(OidcStrategy.name); + private static readonly stateStore = new OidcStateStore(); public constructor( @@ -52,9 +54,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { }); if (!thirdPartyId) { - Logger.error( - `Missing subject identifier in OIDC response from ${issuer}`, - 'OidcStrategy' + this.logger.error( + `Missing subject identifier in OIDC response from ${issuer}` ); throw new Error('Missing subject identifier in OIDC response'); @@ -62,7 +63,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { return { jwt }; } catch (error) { - Logger.error(error, 'OidcStrategy'); + this.logger.error(error); throw error; } } diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index 6cffcd244..5764eeece 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -33,6 +33,8 @@ import ms from 'ms'; @Injectable() export class WebAuthService { + private readonly logger = new Logger(WebAuthService.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly deviceService: AuthDeviceService, @@ -103,7 +105,7 @@ export class WebAuthService { verification = await verifyRegistrationResponse(opts); } catch (error) { - Logger.error(error, 'WebAuthService'); + this.logger.error(error); throw new InternalServerErrorException(error.message); } @@ -210,7 +212,7 @@ export class WebAuthService { verification = await verifyAuthenticationResponse(opts); } catch (error) { - Logger.error(error, 'WebAuthService'); + this.logger.error(error); throw new InternalServerErrorException({ error: error.message }); } diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index 03ff32c21..0b95880d4 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -17,6 +17,8 @@ import { isNumber } from 'lodash'; @Injectable() export class BenchmarksService { + private readonly logger = new Logger(BenchmarksService.name); + public constructor( private readonly benchmarkService: BenchmarkService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -96,12 +98,11 @@ export class BenchmarksService { })?.marketPrice; if (!marketPriceAtStartDate) { - Logger.error( + this.logger.error( `No historical market data has been found for ${symbol} (${dataSource}) at ${format( startDate, DATE_FORMAT - )}`, - 'BenchmarkService' + )}` ); return { marketData }; 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..b84ca881f 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 { @@ -33,9 +34,12 @@ import { Big } from 'big.js'; @Injectable() export class GhostfolioService { + private readonly logger = new Logger(GhostfolioService.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService ) {} @@ -97,7 +101,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -139,7 +143,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -181,7 +185,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -269,7 +273,7 @@ export class GhostfolioService { return results; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -346,7 +350,7 @@ export class GhostfolioService { return results; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -355,6 +359,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/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 35f3fa348..4f88a03f0 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -24,6 +24,8 @@ import { HealthService } from './health.service'; @Controller('health') export class HealthController { + private readonly logger = new Logger(HealthController.name); + public constructor( private readonly aiService: AiService, private readonly healthService: HealthService @@ -61,7 +63,7 @@ export class HealthController { .json({ status: getReasonPhrase(StatusCodes.OK) }); } } catch (error) { - Logger.error(error, 'HealthController'); + this.logger.error(error); } return response diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 521be56f7..c3e79a29f 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -31,6 +31,8 @@ import { ImportService } from './import.service'; @Controller('import') export class ImportController { + private readonly logger = new Logger(ImportController.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly importService: ImportService, @@ -81,7 +83,7 @@ export class ImportController { return { activities }; } catch (error) { - Logger.error(error, ImportController); + this.logger.error(error); throw new HttpException( { 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/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index d57b85d8c..ab3f76703 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -62,6 +62,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash'; export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; + protected readonly logger = new Logger(PortfolioCalculator.name); + protected accountBalanceItems: HistoricalDataItem[]; protected activities: PortfolioOrder[]; @@ -1119,12 +1121,11 @@ export abstract class PortfolioCalculator { if (cachedPortfolioSnapshot) { this.snapshot = cachedPortfolioSnapshot; - Logger.debug( + this.logger.debug( `Fetched portfolio snapshot from cache in ${( (performance.now() - startTimeTotal) / 1000 - ).toFixed(3)} seconds`, - 'PortfolioCalculator' + ).toFixed(3)} seconds` ); if (isCachedPortfolioSnapshotExpired) { diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 2841e9975..d5efc4bf2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -11,7 +11,6 @@ import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { DateRange } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Logger } from '@nestjs/common'; import { Big } from 'big.js'; import { addMilliseconds, @@ -96,9 +95,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { currentPosition.timeWeightedInvestmentWithCurrencyEffect ); } else if (!currentPosition.quantity.eq(0)) { - Logger.warn( - `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, - 'PortfolioCalculator' + this.logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})` ); hasErrors = true; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 8aa94ee92..ca94605f9 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,4 +1,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { @@ -70,7 +71,8 @@ export class PortfolioController { private readonly configurationService: ConfigurationService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService ) {} @Get('details') @@ -144,10 +146,10 @@ export class PortfolioController { .reduce((a, b) => a + b, 0); const totalValue = Object.values(holdings) - .filter(({ assetClass, assetSubClass }) => { + .filter(({ assetProfile }) => { return ( - assetClass !== AssetClass.LIQUIDITY && - assetSubClass !== AssetSubClass.CASH + assetProfile.assetClass !== AssetClass.LIQUIDITY && + assetProfile.assetSubClass !== AssetSubClass.CASH ); }) .map(({ valueInBaseCurrency }) => { @@ -217,37 +219,41 @@ export class PortfolioController { for (const [symbol, portfolioPosition] of Object.entries(holdings)) { holdings[symbol] = { ...portfolioPosition, - assetClass: - hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetClass - : undefined, assetProfile: { ...portfolioPosition.assetProfile, + assetClass: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClass + : undefined, + assetClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClassLabel + : undefined, + assetSubClass: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClass + : undefined, + assetSubClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClassLabel + : undefined, ...(hasDetails ? {} : { - assetClass: undefined, - assetClassLabel: undefined, - assetSubClass: undefined, - assetSubClassLabel: undefined, countries: [], currency: undefined, holdings: [], sectors: [] }) }, - assetSubClass: - hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetSubClass - : undefined, - countries: hasDetails ? portfolioPosition.countries : [], - currency: hasDetails ? portfolioPosition.currency : undefined, - holdings: hasDetails ? portfolioPosition.holdings : [], markets: hasDetails ? portfolioPosition.markets : undefined, marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced - : undefined, - sectors: hasDetails ? portfolioPosition.sectors : [] + : undefined }; } @@ -336,7 +342,10 @@ export class PortfolioController { const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); - const userCurrency = this.request.user.settings.settings.baseCurrency; + const userId = impersonationUserId || this.request.user.id; + + const { settings } = await this.userService.user({ id: userId }); + const userCurrency = settings.settings.baseCurrency; const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); @@ -345,7 +354,7 @@ export class PortfolioController { filters, startDate, userCurrency, - userId: impersonationUserId || this.request.user.id, + userId, types: ['DIVIDEND'] }); 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/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index cee36ec27..4feb0f77a 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -108,6 +108,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json'); @Injectable() export class PortfolioService { + private readonly logger = new Logger(PortfolioService.name); + public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, @@ -164,7 +166,7 @@ export class PortfolioService { }; } - const [accounts, details] = await Promise.all([ + const [accounts, details, user] = await Promise.all([ this.accountService.accounts({ where, include: { @@ -178,10 +180,11 @@ export class PortfolioService { withExcludedAccounts, impersonationId: userId, userId: this.request.user.id - }) + }), + this.userService.user({ id: userId }) ]); - const userCurrency = this.request.user.settings.settings.baseCurrency; + const userCurrency = this.getUserCurrency(user); return Promise.all( accounts.map(async (account) => { @@ -584,7 +587,6 @@ export class PortfolioService { for (const { activitiesCount, - currency, dataSource, dateOfFirstActivity, dividend, @@ -619,9 +621,8 @@ export class PortfolioService { symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })]; if (!assetProfile) { - Logger.warn( - `Asset profile not found for ${symbol} (${dataSource})`, - 'PortfolioService' + this.logger.warn( + `Asset profile not found for ${symbol} (${dataSource})` ); continue; @@ -638,16 +639,13 @@ export class PortfolioService { holdings[symbol] = { activitiesCount, - currency, markets, marketsAdvanced, marketPrice, - symbol, tags, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), - assetClass: assetProfile.assetClass, assetProfile: { assetClass: assetProfile.assetClass, assetSubClass: assetProfile.assetSubClass, @@ -670,9 +668,6 @@ export class PortfolioService { symbol: assetProfile.symbol, url: assetProfile.url }, - assetSubClass: assetProfile.assetSubClass, - countries: assetProfile.countries, - dataSource: assetProfile.dataSource, dateOfFirstActivity: parseDate(dateOfFirstActivity), dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, @@ -681,19 +676,7 @@ export class PortfolioService { grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, - holdings: assetProfile.holdings.map( - ({ allocationInPercentage, name }) => { - return { - allocationInPercentage, - name, - valueInBaseCurrency: valueInBaseCurrency - .mul(allocationInPercentage) - .toNumber() - }; - } - ), investment: investment.toNumber(), - name: assetProfile.name, netPerformance: netPerformance?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercentWithCurrencyEffect: @@ -703,8 +686,6 @@ export class PortfolioService { netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, quantity: quantity.toNumber(), - sectors: assetProfile.sectors, - url: assetProfile.url, valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } @@ -1472,8 +1453,8 @@ export class PortfolioService { for (const [, position] of Object.entries(holdings)) { const value = position.valueInBaseCurrency; - if (position.assetClass !== AssetClass.LIQUIDITY) { - if (position.countries.length > 0) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.countries.length > 0) { markets.developedMarkets.valueInBaseCurrency += position.markets.developedMarkets * value; markets.emergingMarkets.valueInBaseCurrency += @@ -1719,11 +1700,8 @@ export class PortfolioService { currency: string; }): PortfolioPosition { return { - currency, activitiesCount: 0, allocationInPercentage: 0, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, assetProfile: { currency, assetClass: AssetClass.LIQUIDITY, @@ -1735,25 +1713,19 @@ export class PortfolioService { sectors: [], symbol: currency }, - countries: [], - dataSource: undefined, dateOfFirstActivity: undefined, dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0, - holdings: [], investment: balance, marketPrice: 0, - name: currency, netPerformance: 0, netPerformancePercent: 0, netPerformancePercentWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, quantity: 0, - sectors: [], - symbol: currency, tags: [], valueInBaseCurrency: balance }; diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts index 619d23fc5..b87740f8c 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -10,6 +10,8 @@ import { createHash, randomUUID } from 'node:crypto'; @Injectable() export class RedisCacheService { + private readonly logger = new Logger(RedisCacheService.name); + private client: Keyv; public constructor( @@ -27,7 +29,7 @@ export class RedisCacheService { }; this.client.on('error', (error) => { - Logger.error(error, 'RedisCacheService'); + this.logger.error(error); }); } @@ -101,7 +103,7 @@ export class RedisCacheService { return true; } catch (error) { - Logger.error(error?.message, 'RedisCacheService'); + this.logger.error(error?.message); return false; } finally { diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 3e6316ec6..074a9db0e 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -33,6 +33,8 @@ import { SubscriptionService } from './subscription.service'; @Controller('subscription') export class SubscriptionController { + private readonly logger = new Logger(SubscriptionController.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly propertyService: PropertyService, @@ -80,9 +82,8 @@ export class SubscriptionController { value: JSON.stringify(coupons) }); - Logger.log( - `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`, - 'SubscriptionController' + this.logger.log( + `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}` ); return { @@ -101,9 +102,8 @@ export class SubscriptionController { ); if (userId) { - Logger.log( - `Subscription for user '${userId}' has been created via Stripe`, - 'SubscriptionController' + this.logger.log( + `Subscription for user '${userId}' has been created via Stripe` ); } @@ -126,7 +126,7 @@ export class SubscriptionController { user: this.request.user }); } catch (error) { - Logger.error(error, 'SubscriptionController'); + this.logger.error(error); throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 557d81976..a811d2243 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -24,6 +24,8 @@ import Stripe from 'stripe'; @Injectable() export class SubscriptionService { + private readonly logger = new Logger(SubscriptionService.name); + private stripe: Stripe; public constructor( @@ -166,9 +168,8 @@ export class SubscriptionService { error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { - Logger.log( - `Stripe Checkout Session '${session.id}' has already been redeemed`, - 'SubscriptionService' + this.logger.log( + `Stripe Checkout Session '${session.id}' has already been redeemed` ); } else { throw error; @@ -177,7 +178,7 @@ export class SubscriptionService { return session.client_reference_id; } catch (error) { - Logger.error(error, 'SubscriptionService'); + this.logger.error(error); } } diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 15498e80d..fdbc7f84c 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -15,6 +15,8 @@ import { format, subDays } from 'date-fns'; @Injectable() export class SymbolService { + private readonly logger = new Logger(SymbolService.name); + public constructor( private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService @@ -119,7 +121,7 @@ export class SymbolService { results.items = items; return results; } catch (error) { - Logger.error(error, 'SymbolService'); + this.logger.error(error); throw error; } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4a0e1598b..9d8d9da9d 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -49,7 +49,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Prisma, Role, User } from '@prisma/client'; +import { Prisma, Role, Settings, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; import { without } from 'lodash'; import { createHmac } from 'node:crypto'; @@ -109,7 +109,14 @@ export class UserService { }): Promise { const { id, permissions, settings, subscription } = user; - const userData = await Promise.all([ + const [ + access, + accounts, + activitiesCount, + firstActivity, + impersonationUserSettings, + tagsForUser + ] = await Promise.all([ this.prismaService.access.findMany({ include: { user: true @@ -134,16 +141,17 @@ export class UserService { }, where: { userId: impersonationUserId || user.id } }), + impersonationUserId + ? this.prismaService.settings.findUnique({ + where: { userId: impersonationUserId } + }) + : Promise.resolve(null), this.tagService.getTagsForUser(impersonationUserId || user.id) ]); - const access = userData[0]; - const accounts = userData[1]; - const activitiesCount = userData[2]; - const firstActivity = userData[3]; - let tags = userData[4].filter((tag) => { - return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; - }); + const baseCurrency = + (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? + (settings.settings as UserSettings)?.baseCurrency; let systemMessage: SystemMessage; @@ -156,6 +164,10 @@ export class UserService { systemMessage = systemMessageProperty; } + let tags = tagsForUser.filter((tag) => { + return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; + }); + if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription.type === SubscriptionType.Basic @@ -183,6 +195,7 @@ export class UserService { dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(settings.settings as UserSettings), + baseCurrency, locale: (settings.settings as UserSettings)?.locale ?? locale } }; diff --git a/apps/api/src/events/asset-profile-changed.listener.ts b/apps/api/src/events/asset-profile-changed.listener.ts index cc70edad6..e2aea382e 100644 --- a/apps/api/src/events/asset-profile-changed.listener.ts +++ b/apps/api/src/events/asset-profile-changed.listener.ts @@ -15,6 +15,8 @@ import { AssetProfileChangedEvent } from './asset-profile-changed.event'; @Injectable() export class AssetProfileChangedListener { + private readonly logger = new Logger(AssetProfileChangedListener.name); + private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private debounceTimers = new Map(); @@ -67,10 +69,7 @@ export class AssetProfileChangedListener { dataSource: DataSource; symbol: string; }) { - Logger.log( - `Asset profile of ${symbol} (${dataSource}) has changed`, - 'AssetProfileChangedListener' - ); + this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`); if ( this.configurationService.get( @@ -84,10 +83,7 @@ export class AssetProfileChangedListener { const existingCurrencies = this.exchangeRateDataService.getCurrencies(); if (!existingCurrencies.includes(currency)) { - Logger.log( - `New currency ${currency} has been detected`, - 'AssetProfileChangedListener' - ); + this.logger.log(`New currency ${currency} has been detected`); await this.exchangeRateDataService.initialize(); } diff --git a/apps/api/src/events/portfolio-changed.listener.ts b/apps/api/src/events/portfolio-changed.listener.ts index f8e2a9229..12441517b 100644 --- a/apps/api/src/events/portfolio-changed.listener.ts +++ b/apps/api/src/events/portfolio-changed.listener.ts @@ -8,6 +8,8 @@ import { PortfolioChangedEvent } from './portfolio-changed.event'; @Injectable() export class PortfolioChangedListener { + private readonly logger = new Logger(PortfolioChangedListener.name); + private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private debounceTimers = new Map(); @@ -35,10 +37,7 @@ export class PortfolioChangedListener { } private async processPortfolioChanged({ userId }: { userId: string }) { - Logger.log( - `Portfolio of user '${userId}' has changed`, - 'PortfolioChangedListener' - ); + this.logger.log(`Portfolio of user '${userId}' has changed`); await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); } diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.service.ts b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts index 1b1faf8e0..a07783cd9 100644 --- a/apps/api/src/interceptors/performance-logging/performance-logging.service.ts +++ b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; @Injectable() export class PerformanceLoggingService { + private readonly logger = new Logger(PerformanceLoggingService.name); + public logPerformance({ className, methodName, @@ -13,7 +15,7 @@ export class PerformanceLoggingService { }) { const endTime = performance.now(); - Logger.debug( + this.logger.debug( `Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, className ); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f08a09a83..63185a48b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -18,11 +18,17 @@ 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'; +const logger = new Logger('Bootstrap'); + 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[]; @@ -110,20 +116,20 @@ async function bootstrap() { address = `${host}:${addressObject.port}`; } - Logger.log(`Listening at http://${address}`); - Logger.log(''); + logger.log(`Listening at http://${address}`); + logger.log(''); }); } function logLogo() { - Logger.log(' ________ __ ____ ___'); - Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); - Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); - Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); - Logger.log( + logger.log(' ________ __ ____ ___'); + logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); + logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); + logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); + logger.log( `\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}` ); - Logger.log(''); + logger.log(''); } bootstrap(); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index 2b8820e81..c256ada56 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -92,6 +92,8 @@ const locales = { @Injectable() export class HtmlTemplateMiddleware implements NestMiddleware { + private readonly logger = new Logger(HtmlTemplateMiddleware.name); + private indexHtmlMap: { [languageCode: string]: string } = {}; public constructor(private readonly i18nService: I18nService) { @@ -107,11 +109,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware { {} ); } catch (error) { - Logger.error( - 'Failed to initialize index HTML map', - error, - 'HTMLTemplateMiddleware' - ); + this.logger.error('Failed to initialize index HTML map', error); } } diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 4b1d9a65f..022a0e928 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -28,6 +28,8 @@ import { BenchmarkValue } from './interfaces/benchmark-value.interface'; @Injectable() export class BenchmarkService { + private readonly logger = new Logger(BenchmarkService.name); + private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; public constructor( @@ -87,7 +89,7 @@ export class BenchmarkService { const { benchmarks, expiration }: BenchmarkValue = JSON.parse(cachedBenchmarkValue); - Logger.debug('Fetched benchmarks from cache', 'BenchmarkService'); + this.logger.debug('Fetched benchmarks from cache'); if (isAfter(new Date(), new Date(expiration))) { this.calculateAndCacheBenchmarks({ @@ -227,7 +229,7 @@ export class BenchmarkService { private async calculateAndCacheBenchmarks({ enableSharing = false }): Promise { - Logger.debug('Calculate benchmarks', 'BenchmarkService'); + this.logger.debug('Calculate benchmarks'); const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ enableSharing 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..5d6ed79aa 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 { @@ -28,11 +29,14 @@ import { format, fromUnixTime, getUnixTime } from 'date-fns'; @Injectable() export class CoinGeckoService implements DataProviderInterface, OnModuleInit { + private readonly logger = new Logger(CoinGeckoService.name); + private apiUrl: string; private headers: HeadersInit = {}; public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public onModuleInit() { @@ -67,12 +71,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) { @@ -84,7 +90,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return response; @@ -118,13 +124,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 +189,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] = { @@ -209,7 +216,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return response; @@ -230,13 +237,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 { @@ -258,7 +264,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return { items }; 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..a74aaeb46 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'; @@ -10,9 +11,12 @@ import { countries } from 'countries-list'; @Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { + private readonly logger = new Logger(TrackinsightDataEnhancerService.name); + private static baseUrl = 'https://www.trackinsight.com/data-api'; private static countriesMapping = { - 'Russian Federation': 'Russia' + 'Russian Federation': 'Russia', + USA: 'United States' }; private static holdingsWeightTreshold = 0.85; private static sectorsMapping = { @@ -23,7 +27,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 +65,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 +89,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 +189,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 ( @@ -203,9 +211,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return undefined; }) .catch(({ message }) => { - Logger.error( - `Failed to search Trackinsight symbol for ${symbol} (${message})`, - 'TrackinsightDataEnhancerService' + this.logger.error( + `Failed to search Trackinsight symbol for ${symbol} (${message})` ); return undefined; diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 30ad81c09..034916a5f 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -23,6 +23,8 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; @Injectable() export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + private readonly logger = new Logger(YahooFinanceDataEnhancerService.name); + private readonly yahooFinance = new YahooFinance({ suppressNotices: ['yahooSurvey'] }); @@ -123,7 +125,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { response.url = url; } } catch (error) { - Logger.error(error, 'YahooFinanceDataEnhancerService'); + this.logger.error(error); } return response; @@ -266,7 +268,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { `No data found, ${aSymbol} (${this.getName()}) may be delisted` ); } else { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); } } 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/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 5f0a6928a..1ea2d6436 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -41,6 +41,8 @@ import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error'; @Injectable() export class DataProviderService implements OnModuleInit { + private readonly logger = new Logger(DataProviderService.name); + private dataProviderMapping: { [dataProviderName: string]: string }; public constructor( @@ -129,7 +131,7 @@ export class DataProviderService implements OnModuleInit { ); } } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); throw error; } @@ -391,7 +393,7 @@ export class DataProviderService implements OnModuleInit { return r; }, {}); } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); } finally { return response; } @@ -503,7 +505,7 @@ export class DataProviderService implements OnModuleInit { result[symbol] = data; } } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); throw error; } @@ -567,13 +569,12 @@ export class DataProviderService implements OnModuleInit { const numberOfItemsInCache = Object.keys(response)?.length; if (numberOfItemsInCache) { - Logger.debug( + this.logger.debug( `Fetched ${numberOfItemsInCache} quote${ numberOfItemsInCache > 1 ? 's' : '' } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( 3 - )} seconds`, - 'DataProviderService' + )} seconds` ); } @@ -684,14 +685,13 @@ export class DataProviderService implements OnModuleInit { } } - Logger.debug( + this.logger.debug( `Fetched ${symbolsChunk.length} quote${ symbolsChunk.length > 1 ? 's' : '' } from ${dataSource} in ${( (performance.now() - startTimeDataSource) / 1000 - ).toFixed(3)} seconds`, - 'DataProviderService' + ).toFixed(3)} seconds` ); try { @@ -722,15 +722,18 @@ export class DataProviderService implements OnModuleInit { await Promise.all(promises); - Logger.debug('--------------------------------------------------------'); - Logger.debug( + this.logger.debug( + '--------------------------------------------------------' + ); + this.logger.debug( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( (performance.now() - startTimeTotal) / 1000 - ).toFixed(3)} seconds`, - 'DataProviderService' + ).toFixed(3)} seconds` + ); + this.logger.debug( + '========================================================' ); - Logger.debug('========================================================'); return response; } 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..06173c25b 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, @@ -36,11 +37,14 @@ import { isNumber } from 'lodash'; export class EodHistoricalDataService implements DataProviderInterface, OnModuleInit { + private readonly logger = new Logger(EodHistoricalDataService.name); + private apiKey: string; private readonly URL = 'https://eodhistoricaldata.com/api'; public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -111,12 +115,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] = { @@ -126,12 +129,11 @@ export class EodHistoricalDataService return response; } catch (error) { - Logger.error( + this.logger.error( `Could not get dividends for ${symbol} (${this.getName()}) from ${format( from, DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, - 'EodHistoricalDataService' + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` ); return {}; @@ -158,12 +160,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 }) => { @@ -172,9 +173,8 @@ export class EodHistoricalDataService marketPrice: adjusted_close }; } else { - Logger.error( - `Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`, - 'EodHistoricalDataService' + this.logger.error( + `Could not get historical market data for ${symbol} (${this.getName()}) at ${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; @@ -290,9 +292,8 @@ export class EodHistoricalDataService dataSource: this.getName() }; } else { - Logger.error( - `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, - 'EodHistoricalDataService' + this.logger.error( + `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})` ); } } @@ -309,7 +310,7 @@ export class EodHistoricalDataService ).toFixed(3)} seconds`; } - Logger.error(message, 'EodHistoricalDataService'); + this.logger.error(message); } return {}; @@ -430,12 +431,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 }) => { @@ -464,7 +464,7 @@ export class EodHistoricalDataService ).toFixed(3)} seconds`; } - Logger.error(message, 'EodHistoricalDataService'); + this.logger.error(message); } return searchResult; 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..80eeadeb0 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, @@ -48,6 +49,8 @@ import { uniqBy } from 'lodash'; export class FinancialModelingPrepService implements DataProviderInterface, OnModuleInit { + private readonly logger = new Logger(FinancialModelingPrepService.name); + private static countriesMapping = { 'Korea (the Republic of)': 'South Korea', 'Russian Federation': 'Russia', @@ -59,6 +62,7 @@ export class FinancialModelingPrepService public constructor( private readonly configurationService: ConfigurationService, private readonly cryptocurrencyService: CryptocurrencyService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService ) {} @@ -96,12 +100,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 +121,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 +151,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 +184,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 +205,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 }) => { @@ -251,7 +267,7 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + this.logger.error(message); } return response; @@ -286,12 +302,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 }) => { @@ -309,12 +327,11 @@ export class FinancialModelingPrepService return response; } catch (error) { - Logger.error( + this.logger.error( `Could not get dividends for ${symbol} (${this.getName()}) from ${format( from, DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, - 'FinancialModelingPrepService' + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` ); return {}; @@ -354,12 +371,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 +441,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) { @@ -497,7 +519,7 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + this.logger.error(message); } return response; @@ -525,12 +547,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 +582,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( @@ -611,7 +639,7 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + this.logger.error(message); } return { items }; 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..2b91855a6 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, @@ -32,12 +33,15 @@ import { StatusCodes } from 'http-status-codes'; @Injectable() export class GhostfolioService implements DataProviderInterface { + private readonly logger = new Logger(GhostfolioService.name); + private readonly URL = environment.production ? 'https://ghostfol.io/api' : `${this.configurationService.get('ROOT_URL')}/api`; public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly propertyService: PropertyService ) {} @@ -52,7 +56,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(), @@ -87,7 +91,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return assetProfile; @@ -122,7 +126,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(), @@ -152,7 +156,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return dividends; @@ -174,7 +178,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(), @@ -209,7 +213,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(error.message, 'GhostfolioService'); + this.logger.error(error.message); throw new Error( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( @@ -245,7 +249,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(), @@ -281,7 +285,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return quotes; @@ -302,7 +306,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(), @@ -336,7 +340,7 @@ export class GhostfolioService implements DataProviderInterface { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; } - Logger.error(message, 'GhostfolioService'); + this.logger.error(message); } return searchResult; diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts index ba1e5bbe5..13f671bd4 100644 --- a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -24,6 +24,8 @@ import { GoogleSpreadsheet } from 'google-spreadsheet'; @Injectable() export class GoogleSheetsService implements DataProviderInterface { + private readonly logger = new Logger(GoogleSheetsService.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, @@ -144,7 +146,7 @@ export class GoogleSheetsService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'GoogleSheetsService'); + this.logger.error(error); } return {}; 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..87e116dda 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 { @@ -30,8 +31,11 @@ import { addDays, format, isBefore } from 'date-fns'; @Injectable() export class ManualService implements DataProviderInterface { + private readonly logger = new Logger(ManualService.name); + public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -179,9 +183,8 @@ export class ManualService implements DataProviderInterface { }); return { marketPrice, symbol }; } catch (error) { - Logger.error( - `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`, - 'ManualService' + this.logger.error( + `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}` ); return { symbol, marketPrice: undefined }; } @@ -214,7 +217,7 @@ export class ManualService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'ManualService'); + this.logger.error(error); } return {}; @@ -292,7 +295,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..9941ae9eb 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 @@ -25,8 +26,11 @@ import { format } from 'date-fns'; @Injectable() export class RapidApiService implements DataProviderInterface { + private readonly logger = new Logger(RapidApiService.name); + public constructor( - private readonly configurationService: ConfigurationService + private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService ) {} public canHandle() { @@ -120,7 +124,7 @@ export class RapidApiService implements DataProviderInterface { }; } } catch (error) { - Logger.error(error, 'RapidApiService'); + this.logger.error(error); } return {}; @@ -142,9 +146,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 +156,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) { @@ -166,7 +169,7 @@ export class RapidApiService implements DataProviderInterface { ).toFixed(3)} seconds`; } - Logger.error(message, 'RapidApiService'); + this.logger.error(message); return undefined; } diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index de8807098..93949ebc0 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -41,6 +41,8 @@ import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search'; @Injectable() export class YahooFinanceService implements DataProviderInterface { + private readonly logger = new Logger(YahooFinanceService.name); + private readonly yahooFinance = new YahooFinance({ suppressNotices: ['yahooSurvey'] }); @@ -105,12 +107,11 @@ export class YahooFinanceService implements DataProviderInterface { return response; } catch (error) { - Logger.error( + this.logger.error( `Could not get dividends for ${symbol} (${this.getName()}) from ${format( from, DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, - 'YahooFinanceService' + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` ); return {}; @@ -198,12 +199,9 @@ export class YahooFinanceService implements DataProviderInterface { try { quotes = await this.yahooFinance.quote(yahooFinanceSymbols); } catch (error) { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); - Logger.warn( - 'Fallback to yahooFinance.quoteSummary()', - 'YahooFinanceService' - ); + this.logger.warn('Fallback to yahooFinance.quoteSummary()'); quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols); } @@ -229,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); return {}; } @@ -334,7 +332,7 @@ export class YahooFinanceService implements DataProviderInterface { }); } } catch (error) { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); } return { items }; @@ -365,10 +363,7 @@ export class YahooFinanceService implements DataProviderInterface { .filter( (result): result is PromiseFulfilledResult => { if (result.status === 'rejected') { - Logger.error( - `Could not get quote summary: ${result.reason}`, - 'YahooFinanceService' - ); + this.logger.error(`Could not get quote summary: ${result.reason}`); return false; } diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 024bdf4e1..708bfa591 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -30,6 +30,8 @@ import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interfa @Injectable() export class ExchangeRateDataService { + private readonly logger = new Logger(ExchangeRateDataService.name); + private currencies: string[] = []; private currencyPairs: DataGatheringItem[] = []; private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; @@ -110,9 +112,8 @@ export class ExchangeRateDataService { previousExchangeRate; if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { - Logger.error( - `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, - 'ExchangeRateDataService' + this.logger.error( + `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}` ); } } else { @@ -253,9 +254,8 @@ export class ExchangeRateDataService { } // Fallback with error, if currencies are not available - Logger.error( - `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, - 'ExchangeRateDataService' + this.logger.error( + `No exchange rate has been found for ${aFromCurrency}${aToCurrency}` ); return aValue; @@ -341,12 +341,11 @@ export class ExchangeRateDataService { return factor * aValue; } - Logger.error( + this.logger.error( `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( aDate, DATE_FORMAT - )}`, - 'ExchangeRateDataService' + )}` ); return undefined; @@ -483,7 +482,7 @@ export class ExchangeRateDataService { errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; } - Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); + this.logger.error(`${errorMessage}.`); } } } 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..16e6f5f5d --- /dev/null +++ b/apps/api/src/services/fetch/fetch.module.ts @@ -0,0 +1,11 @@ +import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [FetchService], + imports: [PropertyModule], + 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..31034f81c --- /dev/null +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -0,0 +1,199 @@ +import { redactPaths } from '@ghostfolio/api/helper/object.helper'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_API_KEY_OPENROUTER, + PROPERTY_OPENROUTER_MODEL, + PROPERTY_WEB_FETCH_ROUTES +} from '@ghostfolio/common/config'; + +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText, jsonSchema, tool } from 'ai'; +import ms from 'ms'; + +import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; + +@Injectable() +export class FetchService implements OnModuleInit { + private readonly logger = new Logger(FetchService.name); + + private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; + private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); + + private webFetchRoutes: WebFetchRoute[] = []; + + public constructor(private readonly propertyService: PropertyService) {} + + public async onModuleInit() { + this.webFetchRoutes = + (await this.propertyService.getByKey( + PROPERTY_WEB_FETCH_ROUTES + )) ?? []; + } + + public async fetch(input: RequestInfo | URL, init?: RequestInit) { + 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); + + this.logger.debug(`${method} ${urlRedacted}`); + + if (method === 'GET') { + const webFetchRoute = this.getMatchingWebFetchRoute(url); + + if (webFetchRoute) { + const response = await this.fetchViaWebFetchTool({ + url, + webFetchRoute + }); + + if (response) { + return response; + } + } + } + + try { + return await globalThis.fetch(input, init); + } catch (error) { + if (error instanceof Error) { + this.logger.error( + `${method} ${urlRedacted} failed: [${error.name}] ${error.message}` + ); + } else { + this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`); + } + + throw error; + } + } + + private async fetchViaWebFetchTool({ + url, + webFetchRoute + }: { + url: string; + webFetchRoute: WebFetchRoute; + }) { + const [openRouterApiKey, openRouterModel] = await Promise.all([ + this.propertyService.getByKey(PROPERTY_API_KEY_OPENROUTER), + this.propertyService.getByKey(PROPERTY_OPENROUTER_MODEL) + ]); + + if (!openRouterApiKey || !openRouterModel) { + return undefined; + } + + try { + const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); + + const { sources, text } = await generateText({ + model: openRouterService.chat(openRouterModel), + prompt: [ + 'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.', + 'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.', + `URL: ${url}` + ].join('\n'), + timeout: FetchService.WEB_FETCH_TIMEOUT, + tools: { + // Provider-defined tool: lets OpenRouter perform the actual web + // request server-side via its `web_fetch` engine. `id` and `args` + // are the OpenRouter-specific identifiers; the input schema is left + // open as the arguments are supplied by the model. + web_fetch: tool({ + args: { engine: 'openrouter' }, + id: 'openrouter.web_fetch', + inputSchema: jsonSchema({ + additionalProperties: true, + type: 'object' + }), + type: 'provider' + }) + } + }); + + const candidates = [ + ...(sources ?? []).map((source) => { + return source.providerMetadata?.openrouter?.content; + }), + text + ]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') { + continue; + } + + const body = candidate.trim(); + + if (!body) { + continue; + } + + if (webFetchRoute.responseContentType?.includes('application/json')) { + try { + JSON.parse(body); + } catch { + continue; + } + } + + this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`); + + return new Response(body, { + headers: webFetchRoute.responseContentType + ? { 'content-type': webFetchRoute.responseContentType } + : undefined + }); + } + + return undefined; + } catch (error) { + this.logger.error( + `Web fetch tool failed for ${this.redactUrl(url)}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + + return undefined; + } + } + + private getMatchingWebFetchRoute(url: string) { + try { + const { hostname } = new URL(url); + + return this.webFetchRoutes.find(({ domain }) => { + return hostname === domain || hostname.endsWith(`.${domain}`); + }); + } catch { + return undefined; + } + } + + 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/fetch/interfaces/web-fetch-route.interface.ts b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts new file mode 100644 index 000000000..efff09398 --- /dev/null +++ b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts @@ -0,0 +1,19 @@ +/** + * Routes outgoing GET requests for a given domain through the OpenRouter + * `web_fetch` tool instead of a direct network request. + * + * Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g. + * + * [ + * { + * "domain": "example.com", + * "responseContentType": "application/json" + * } + * ] + * + * Matches the domain itself and its subdomains (e.g. `api.example.com`). + */ +export interface WebFetchRoute { + domain: string; + responseContentType?: string; +} diff --git a/apps/api/src/services/i18n/i18n.service.ts b/apps/api/src/services/i18n/i18n.service.ts index 1cdb811a9..65c51b2f0 100644 --- a/apps/api/src/services/i18n/i18n.service.ts +++ b/apps/api/src/services/i18n/i18n.service.ts @@ -7,6 +7,8 @@ import { join } from 'node:path'; @Injectable() export class I18nService implements OnModuleInit { + private readonly logger = new Logger(I18nService.name); + private localesPath = join(__dirname, 'assets', 'locales'); private translations: { [locale: string]: cheerio.CheerioAPI } = {}; @@ -26,7 +28,7 @@ export class I18nService implements OnModuleInit { const $ = this.translations[languageCode]; if (!$) { - Logger.warn(`Translation not found for locale '${languageCode}'`); + this.logger.warn(`Translation not found for locale '${languageCode}'`); } let translatedText = $( @@ -36,7 +38,7 @@ export class I18nService implements OnModuleInit { ).text(); if (!translatedText) { - Logger.warn( + this.logger.warn( `Translation not found for id '${id}' in locale '${languageCode}'` ); } @@ -60,7 +62,7 @@ export class I18nService implements OnModuleInit { this.parseXml(xmlData); } } catch (error) { - Logger.error(error, 'I18nService'); + this.logger.error(error); } } diff --git a/apps/api/src/services/prisma/prisma.service.ts b/apps/api/src/services/prisma/prisma.service.ts index cdbc1cdfd..ebbd3afd4 100644 --- a/apps/api/src/services/prisma/prisma.service.ts +++ b/apps/api/src/services/prisma/prisma.service.ts @@ -14,6 +14,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PrismaService.name); + public constructor(configService: ConfigService) { const adapter = new PrismaPg({ connectionString: configService.get('DATABASE_URL') @@ -43,7 +45,7 @@ export class PrismaService try { await this.$connect(); } catch (error) { - Logger.error(error, 'PrismaService'); + this.logger.error(error); } } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts index 1a4038652..ee5cb838a 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts @@ -32,6 +32,8 @@ import { DataGatheringService } from './data-gathering.service'; @Injectable() @Processor(DATA_GATHERING_QUEUE) export class DataGatheringProcessor { + private readonly logger = new Logger(DataGatheringProcessor.name); + public constructor( private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, @@ -51,16 +53,14 @@ export class DataGatheringProcessor { const { dataSource, symbol } = job.data; try { - Logger.log( - `Asset profile data gathering has been started for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + this.logger.log( + `Asset profile data gathering has been started for ${symbol} (${dataSource})` ); await this.dataGatheringService.gatherAssetProfiles([job.data]); - Logger.log( - `Asset profile data gathering has been completed for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + this.logger.log( + `Asset profile data gathering has been completed for ${symbol} (${dataSource})` ); } catch (error) { if (error instanceof AssetProfileDelistedError) { @@ -74,18 +74,14 @@ export class DataGatheringProcessor { } ); - Logger.log( - `Asset profile data gathering has been discarded for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + this.logger.log( + `Asset profile data gathering has been discarded for ${symbol} (${dataSource})` ); return job.discard(); } - Logger.error( - error, - `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` - ); + this.logger.error(error); throw error; } @@ -105,12 +101,11 @@ export class DataGatheringProcessor { try { let currentDate = parseISO(date as unknown as string); - Logger.log( + this.logger.log( `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format( currentDate, DATE_FORMAT - )}${force ? ' (forced update)' : ''}`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + )}${force ? ' (forced update)' : ''}` ); const historicalData = await this.dataProviderService.getHistoricalRaw({ @@ -167,12 +162,11 @@ export class DataGatheringProcessor { await this.marketDataService.updateMany({ data }); } - Logger.log( + this.logger.log( `Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format( currentDate, DATE_FORMAT - )}`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + )}` ); } catch (error) { if (error instanceof AssetProfileDelistedError) { @@ -186,18 +180,14 @@ export class DataGatheringProcessor { } ); - Logger.log( - `Historical market data gathering has been discarded for ${symbol} (${dataSource})`, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + this.logger.log( + `Historical market data gathering has been discarded for ${symbol} (${dataSource})` ); return job.discard(); } - Logger.error( - error, - `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` - ); + this.logger.error(error); throw error; } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index cec63c3eb..b5b701fe4 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -34,6 +34,8 @@ import ms, { StringValue } from 'ms'; @Injectable() export class DataGatheringService { + private readonly logger = new Logger(DataGatheringService.name); + public constructor( @Inject('DataEnhancers') private readonly dataEnhancers: DataEnhancerInterface[], @@ -145,7 +147,7 @@ export class DataGatheringService { }); } } catch (error) { - Logger.error(error, 'DataGatheringService'); + this.logger.error(error); } finally { return undefined; } @@ -176,30 +178,42 @@ export class DataGatheringService { ); for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { - const symbolMapping = symbolProfiles.find((symbolProfile) => { - return symbolProfile.symbol === symbol; - })?.symbolMapping; + const symbolProfile = symbolProfiles.find( + ({ symbol: symbolProfileSymbol }) => { + return symbolProfileSymbol === symbol; + } + ); + + const symbolMapping = symbolProfile?.symbolMapping; + + let enhancedAssetProfile = symbolProfile + ? { + ...assetProfile, + assetClass: symbolProfile.assetClass ?? assetProfile.assetClass, + assetSubClass: + symbolProfile.assetSubClass ?? assetProfile.assetSubClass + } + : assetProfile; for (const dataEnhancer of this.dataEnhancers) { try { - assetProfiles[symbol] = await dataEnhancer.enhance({ - response: assetProfile, + enhancedAssetProfile = await dataEnhancer.enhance({ + response: enhancedAssetProfile, symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol }); } catch (error) { - Logger.error( + this.logger.error( `Failed to enhance data for ${symbol} (${ assetProfile.dataSource }) by ${dataEnhancer.getName()}`, - error, - 'DataGatheringService' + error ); } } + const { assetClass, assetSubClass } = assetProfile; + const { - assetClass, - assetSubClass, countries, currency, cusip, @@ -212,7 +226,7 @@ export class DataGatheringService { name, sectors, url - } = assetProfile; + } = enhancedAssetProfile; try { await this.prismaService.symbolProfile.upsert({ @@ -256,11 +270,7 @@ export class DataGatheringService { } }); } catch (error) { - Logger.error( - `${symbol}: ${error?.meta?.cause}`, - error, - 'DataGatheringService' - ); + this.logger.error(`${symbol}: ${error?.meta?.cause}`, error); if (assetProfileIdentifiers.length === 1) { throw error; diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index f3aa6e77e..cf94a9d2b 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -21,6 +21,8 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue @Injectable() @Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) export class PortfolioSnapshotProcessor { + private readonly logger = new Logger(PortfolioSnapshotProcessor.name); + public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly activitiesService: ActivitiesService, @@ -41,9 +43,8 @@ export class PortfolioSnapshotProcessor { try { const startTime = performance.now(); - Logger.log( - `Portfolio snapshot calculation of user '${job.data.userId}' has been started`, - `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + this.logger.log( + `Portfolio snapshot calculation of user '${job.data.userId}' has been started` ); const { activities } = @@ -72,12 +73,11 @@ export class PortfolioSnapshotProcessor { const snapshot = await portfolioCalculator.computeSnapshot(); - Logger.log( + this.logger.log( `Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( (performance.now() - startTime) / 1000 - ).toFixed(3)} seconds`, - `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ).toFixed(3)} seconds` ); const expiration = addMilliseconds( @@ -101,10 +101,7 @@ export class PortfolioSnapshotProcessor { return snapshot; } catch (error) { - Logger.error( - error, - `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` - ); + this.logger.error(error); throw new Error(error); } 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..7eefc101f 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, @@ -26,17 +27,17 @@ import { format, subDays } from 'date-fns'; @Injectable() @Processor(STATISTICS_GATHERING_QUEUE) export class StatisticsGatheringProcessor { + private readonly logger = new Logger(StatisticsGatheringProcessor.name); + public constructor( private readonly configurationService: ConfigurationService, + private readonly fetchService: FetchService, private readonly propertyService: PropertyService ) {} @Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME) public async gatherDockerHubPullsStatistics() { - Logger.log( - 'Docker Hub pulls statistics gathering has been started', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Docker Hub pulls statistics gathering has been started'); const dockerHubPulls = await this.countDockerHubPulls(); @@ -45,17 +46,13 @@ export class StatisticsGatheringProcessor { value: String(dockerHubPulls) }); - Logger.log( - 'Docker Hub pulls statistics gathering has been completed', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Docker Hub pulls statistics gathering has been completed'); } @Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME) public async gatherGitHubContributorsStatistics() { - Logger.log( - 'GitHub contributors statistics gathering has been started', - 'StatisticsGatheringProcessor' + this.logger.log( + 'GitHub contributors statistics gathering has been started' ); const gitHubContributors = await this.countGitHubContributors(); @@ -65,18 +62,14 @@ export class StatisticsGatheringProcessor { value: String(gitHubContributors) }); - Logger.log( - 'GitHub contributors statistics gathering has been completed', - 'StatisticsGatheringProcessor' + this.logger.log( + 'GitHub contributors statistics gathering has been completed' ); } @Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME) public async gatherGitHubStargazersStatistics() { - Logger.log( - 'GitHub stargazers statistics gathering has been started', - 'StatisticsGatheringProcessor' - ); + this.logger.log('GitHub stargazers statistics gathering has been started'); const gitHubStargazers = await this.countGitHubStargazers(); @@ -85,9 +78,8 @@ export class StatisticsGatheringProcessor { value: String(gitHubStargazers) }); - Logger.log( - 'GitHub stargazers statistics gathering has been completed', - 'StatisticsGatheringProcessor' + this.logger.log( + 'GitHub stargazers statistics gathering has been completed' ); } @@ -98,18 +90,14 @@ export class StatisticsGatheringProcessor { ); if (!monitorId) { - Logger.log( - `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`, - 'StatisticsGatheringProcessor' + this.logger.log( + `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured` ); return; } - Logger.log( - 'Uptime statistics gathering has been started', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Uptime statistics gathering has been started'); const uptime = await this.getUptime(monitorId); @@ -118,27 +106,23 @@ export class StatisticsGatheringProcessor { value: String(uptime) }); - Logger.log( - 'Uptime statistics gathering has been completed', - 'StatisticsGatheringProcessor' - ); + this.logger.log('Uptime statistics gathering has been completed'); } 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) { - Logger.error(error, 'StatisticsGatheringProcessor - DockerHub'); + this.logger.error(error); throw error; } @@ -146,11 +130,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); @@ -166,7 +152,7 @@ export class StatisticsGatheringProcessor { value }); } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); + this.logger.error(error); throw error; } @@ -174,19 +160,18 @@ 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) { - Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); + this.logger.error(error); throw error; } @@ -194,26 +179,28 @@ 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) { - Logger.error(error, 'StatisticsGatheringProcessor - Better Stack'); + this.logger.error(error); throw error; } diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 4c2c42589..413b7db03 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -1,5 +1,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { applyAssetProfileOverrides } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, EnhancedSymbolProfile, @@ -192,21 +193,28 @@ export class SymbolProfileService { })[] ): EnhancedSymbolProfile[] { return symbolProfiles.map((symbolProfile) => { + const symbolProfileWithOverrides = applyAssetProfileOverrides( + symbolProfile, + symbolProfile.SymbolProfileOverrides + ); + const item = { - ...symbolProfile, + ...symbolProfileWithOverrides, activitiesCount: 0, countries: this.getCountries( - symbolProfile?.countries as unknown as Prisma.JsonArray + symbolProfileWithOverrides?.countries as unknown as Prisma.JsonArray ), dateOfFirstActivity: undefined as Date, holdings: this.getHoldings( - symbolProfile?.holdings as unknown as Prisma.JsonArray + symbolProfileWithOverrides?.holdings as unknown as Prisma.JsonArray + ), + scraperConfiguration: this.getScraperConfiguration( + symbolProfileWithOverrides ), - scraperConfiguration: this.getScraperConfiguration(symbolProfile), sectors: this.getSectors( - symbolProfile?.sectors as unknown as Prisma.JsonArray + symbolProfileWithOverrides?.sectors as unknown as Prisma.JsonArray ), - symbolMapping: this.getSymbolMapping(symbolProfile), + symbolMapping: this.getSymbolMapping(symbolProfileWithOverrides), watchedByCount: 0 }; @@ -217,45 +225,7 @@ export class SymbolProfileService { item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; delete item.activities; - if (item.SymbolProfileOverrides) { - item.assetClass = - item.SymbolProfileOverrides.assetClass ?? item.assetClass; - item.assetSubClass = - item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass; - - if ( - (item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray) - ?.length > 0 - ) { - item.countries = this.getCountries( - item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray - ); - } - - if ( - (item.SymbolProfileOverrides.holdings as unknown as Holding[]) - ?.length > 0 - ) { - item.holdings = this.getHoldings( - item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray - ); - } - - item.name = item.SymbolProfileOverrides.name ?? item.name; - - if ( - (item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length > - 0 - ) { - item.sectors = this.getSectors( - item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray - ); - } - - item.url = item.SymbolProfileOverrides.url ?? item.url; - - delete item.SymbolProfileOverrides; - } + delete item.SymbolProfileOverrides; return item; }); diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts index b424f7198..ffd0c5452 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -16,6 +16,8 @@ import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; @Injectable() export class TwitterBotService implements OnModuleInit { + private readonly logger = new Logger(TwitterBotService.name); + private twitterClient: TwitterApiReadWrite; public constructor( @@ -71,13 +73,12 @@ export class TwitterBotService implements OnModuleInit { const { data: createdTweet } = await this.twitterClient.v2.tweet(status); - Logger.log( - `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`, - 'TwitterBotService' + this.logger.log( + `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}` ); } } catch (error) { - Logger.error(error, 'TwitterBotService'); + this.logger.error(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/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss index 73c0c0d74..db23cf0a7 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss @@ -14,4 +14,8 @@ top: 0; } } + + .mat-mdc-dialog-title { + padding-right: 0.5rem !important; + } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index b2a7e0a05..61ca6a6da 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -1,10 +1,10 @@
-
-

- {{ assetProfile?.name ?? data.symbol }} -

+

+ {{ + assetProfile?.name ?? data.symbol + }} -

+
{ + this.fetchUsers({ + pageIndex: this.paginator().pageIndex, + showLoading: false + }); + }); } protected formatDistanceToNow(aDateString: string) { @@ -267,8 +278,13 @@ export class GfAdminUsersComponent implements OnInit { ); } - private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) { - this.isLoading = true; + private fetchUsers({ + pageIndex = 0, + showLoading = true + }: { pageIndex?: number; showLoading?: boolean } = {}) { + if (showLoading) { + this.isLoading = true; + } if (pageIndex === 0 && this.paginator()) { this.paginator().pageIndex = 0; @@ -281,7 +297,7 @@ export class GfAdminUsersComponent implements OnInit { }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ count, users }) => { - this.dataSource = new MatTableDataSource(users); + this.dataSource.data = users; this.totalItems = count; this.isLoading = false; 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/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 1e943824c..decb30682 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -139,7 +139,7 @@ export class GfCreateOrUpdateActivityDialogComponent { return !['CASH'].includes(assetProfile.assetSubClass); }) .sort((a, b) => { - return a.name?.localeCompare(b.name); + return a.assetProfile.name?.localeCompare(b.assetProfile.name); }) .map(({ assetProfile }) => { return { diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 42260d648..c3dbe6cf2 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -226,7 +226,8 @@ export class GfImportActivitiesDialogComponent { this.assetProfileForm.controls.assetProfileIdentifier.disable(); const { dataSource, symbol } = - this.assetProfileForm.controls.assetProfileIdentifier.value ?? {}; + this.assetProfileForm.controls.assetProfileIdentifier.value + ?.assetProfile ?? {}; if (!dataSource || !symbol) { return; diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index a7f8cd2ec..f48b551bb 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -73,15 +73,14 @@ export class GfAllocationsPageComponent implements OnInit { public hasImpersonationId: boolean; public holdings: { [symbol: string]: Pick< - PortfolioPosition, + PortfolioPosition['assetProfile'], | 'assetClass' | 'assetClassLabel' | 'assetSubClass' | 'assetSubClassLabel' | 'currency' - | 'exchange' | 'name' - > & { etfProvider: string; value: number }; + > & { etfProvider: string; exchange?: string; value: number }; }; public isLoading = false; public markets: { @@ -206,7 +205,7 @@ export class GfAllocationsPageComponent implements OnInit { assetSubClass, name }: { - assetSubClass: PortfolioPosition['assetSubClass']; + assetSubClass: PortfolioPosition['assetProfile']['assetSubClass']; name: string; }) { if (assetSubClass === 'ETF') { @@ -333,24 +332,27 @@ export class GfAllocationsPageComponent implements OnInit { this.holdings[symbol] = { value, - assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), - assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, - assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), - assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, - currency: position.currency, + assetClass: + position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass), + assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY, + assetSubClass: + position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass), + assetSubClassLabel: + position.assetProfile.assetSubClassLabel || UNKNOWN_KEY, + currency: position.assetProfile.currency, etfProvider: this.extractEtfProvider({ - assetSubClass: position.assetSubClass, - name: position.name + assetSubClass: position.assetProfile.assetSubClass, + name: position.assetProfile.name }), exchange: position.exchange, - name: position.name + name: position.assetProfile.name }; - if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { // Prepare analysis data by continents, countries, holdings and sectors except for liquidity - if (position.countries.length > 0) { - for (const country of position.countries) { + if (position.assetProfile.countries.length > 0) { + for (const country of position.assetProfile.countries) { const { code, continent, name, weight } = country; if (this.continents[continent]?.value) { @@ -401,12 +403,12 @@ export class GfAllocationsPageComponent implements OnInit { : this.portfolioDetails.holdings[symbol].valueInPercentage; } - if (position.holdings.length > 0) { + if (position.assetProfile.holdings.length > 0) { for (const { allocationInPercentage, name, valueInBaseCurrency - } of position.holdings) { + } of position.assetProfile.holdings) { const normalizedAssetName = this.normalizeAssetName(name); if (this.topHoldingsMap[normalizedAssetName]?.value) { @@ -428,8 +430,8 @@ export class GfAllocationsPageComponent implements OnInit { } } - if (position.sectors.length > 0) { - for (const sector of position.sectors) { + if (position.assetProfile.sectors.length > 0) { + for (const sector of position.assetProfile.sectors) { const { name, weight } = sector; if (this.sectors[name]?.value) { @@ -463,8 +465,8 @@ export class GfAllocationsPageComponent implements OnInit { } this.symbols[prettifySymbol(symbol)] = { - dataSource: position.dataSource, - name: position.name, + dataSource: position.assetProfile.dataSource, + name: position.assetProfile.name, symbol: prettifySymbol(symbol), value: isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency @@ -517,8 +519,8 @@ export class GfAllocationsPageComponent implements OnInit { this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, parents: Object.entries(this.portfolioDetails.holdings) .map(([symbol, holding]) => { - if (holding.holdings.length > 0) { - const currentParentHolding = holding.holdings.find( + if (holding.assetProfile.holdings.length > 0) { + const currentParentHolding = holding.assetProfile.holdings.find( (parentHolding) => { return ( this.normalizeAssetName(parentHolding.name) === @@ -531,7 +533,7 @@ export class GfAllocationsPageComponent implements OnInit { ? { allocationInPercentage: currentParentHolding.valueInBaseCurrency / value, - name: holding.name, + name: holding.assetProfile.name, position: holding, symbol: prettifySymbol(symbol), valueInBaseCurrency: diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 03fd0767a..6c49a9030 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -2,7 +2,10 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/be import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { + DEFAULT_DATE_RANGE, + NUMERICAL_PRECISION_THRESHOLD_6_FIGURES +} from '@ghostfolio/common/config'; import { HistoricalDataItem, InvestmentItem, @@ -24,9 +27,12 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { ChangeDetectorRef, Component, + computed, DestroyRef, + inject, OnInit, - ViewChild + signal, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -64,53 +70,57 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './analysis-page.html' }) export class GfAnalysisPageComponent implements OnInit { - @ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger; - - public benchmark: Partial; - public benchmarkDataItems: HistoricalDataItem[] = []; - public benchmarks: Partial[]; - public bottom3: PortfolioPosition[]; - public deviceType: string; - public dividendsByGroup: InvestmentItem[]; - public dividendTimelineDataLabel = $localize`Dividend`; - public firstOrderDate: Date; - public hasImpersonationId: boolean; - public hasPermissionToReadAiPrompt: boolean; - public investments: InvestmentItem[]; - public investmentTimelineDataLabel = $localize`Investment`; - public investmentsByGroup: InvestmentItem[]; - public isLoadingAnalysisPrompt: boolean; - public isLoadingBenchmarkComparator: boolean; - public isLoadingDividendTimelineChart: boolean; - public isLoadingInvestmentChart: boolean; - public isLoadingInvestmentTimelineChart: boolean; - public isLoadingPortfolioPrompt: boolean; - public mode: GroupBy = 'month'; - public modeOptions: ToggleOption[] = [ + protected benchmark?: Partial; + protected benchmarkDataItems: HistoricalDataItem[] = []; + protected readonly benchmarks: Partial[]; + protected bottom3: PortfolioPosition[]; + protected dividendsByGroup: InvestmentItem[]; + protected readonly dividendTimelineDataLabel = $localize`Dividend`; + protected hasImpersonationId: boolean; + protected hasPermissionToReadAiPrompt: boolean; + protected investments: InvestmentItem[]; + protected readonly investmentTimelineDataLabel = $localize`Investment`; + protected investmentsByGroup: InvestmentItem[]; + protected isLoadingAnalysisPrompt: boolean; + protected isLoadingBenchmarkComparator: boolean; + protected isLoadingDividendTimelineChart: boolean; + protected isLoadingInvestmentChart: boolean; + protected isLoadingInvestmentTimelineChart: boolean; + protected isLoadingPortfolioPrompt: boolean; + protected readonly mode = signal('month'); + protected readonly modeOptions: ToggleOption[] = [ { label: $localize`Monthly`, value: 'month' }, { label: $localize`Yearly`, value: 'year' } ]; - public performance: PortfolioPerformance; - public performanceDataItems: HistoricalDataItem[]; - public performanceDataItemsInPercentage: HistoricalDataItem[]; - public portfolioEvolutionDataLabel = $localize`Investment`; - public precision = 2; - public streaks: PortfolioInvestmentsResponse['streaks']; - public top3: PortfolioPosition[]; - public unitCurrentStreak: string; - public unitLongestStreak: string; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private clipboard: Clipboard, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private snackBar: MatSnackBar, - private userService: UserService - ) { + protected performance: PortfolioPerformance; + protected performanceDataItems: HistoricalDataItem[]; + protected performanceDataItemsInPercentage: HistoricalDataItem[]; + protected readonly portfolioEvolutionDataLabel = $localize`Investment`; + protected precision = 2; + protected streaks: PortfolioInvestmentsResponse['streaks']; + protected top3: PortfolioPosition[]; + protected unitCurrentStreak: string; + protected unitLongestStreak: string; + protected user: User; + + private readonly actionsMenuButton = viewChild.required(MatMenuTrigger); + private readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + private firstOrderDate: Date; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly clipboard = inject(Clipboard); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + + public constructor() { const { benchmarks } = this.dataService.fetchInfo(); this.benchmarks = benchmarks; @@ -123,14 +133,16 @@ export class GfAnalysisPageComponent implements OnInit { ? undefined : this.user?.settings?.savingsRate; - return this.mode === 'year' + if (savingsRatePerMonth === undefined) { + return undefined; + } + + return this.mode() === 'year' ? savingsRatePerMonth * 12 : savingsRatePerMonth; } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -158,7 +170,7 @@ export class GfAnalysisPageComponent implements OnInit { }); } - public onChangeBenchmark(symbolProfileId: string) { + protected onChangeBenchmark(symbolProfileId: string) { this.dataService .putUserSetting({ benchmark: symbolProfileId }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -174,12 +186,12 @@ export class GfAnalysisPageComponent implements OnInit { }); } - public onChangeGroupBy(aMode: GroupBy) { - this.mode = aMode; + protected onChangeGroupBy(aMode: GroupBy) { + this.mode.set(aMode); this.fetchDividendsAndInvestments(); } - public onCopyPromptToClipboard(mode: AiPromptMode) { + protected onCopyPromptToClipboard(mode: AiPromptMode) { if (mode === 'analysis') { this.isLoadingAnalysisPrompt = true; } else if (mode === 'portfolio') { @@ -210,7 +222,7 @@ export class GfAnalysisPageComponent implements OnInit { window.open('https://duck.ai', '_blank'); }); - this.actionsMenuButton.closeMenu(); + this.actionsMenuButton().closeMenu(); if (mode === 'analysis') { this.isLoadingAnalysisPrompt = false; @@ -227,8 +239,8 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchDividends({ filters: this.userService.getFilters(), - groupBy: this.mode, - range: this.user?.settings?.dateRange + groupBy: this.mode(), + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ dividends }) => { @@ -242,15 +254,15 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchInvestments({ filters: this.userService.getFilters(), - groupBy: this.mode, - range: this.user?.settings?.dateRange + groupBy: this.mode(), + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ investments, streaks }) => { this.investmentsByGroup = investments; this.streaks = streaks; this.unitCurrentStreak = - this.mode === 'year' + this.mode() === 'year' ? this.streaks?.currentStreak === 1 ? translate('YEAR') : translate('YEARS') @@ -258,7 +270,7 @@ export class GfAnalysisPageComponent implements OnInit { ? translate('MONTH') : translate('MONTHS'); this.unitLongestStreak = - this.mode === 'year' + this.mode() === 'year' ? this.streaks?.longestStreak === 1 ? translate('YEAR') : translate('YEARS') @@ -278,7 +290,7 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchPortfolioPerformance({ filters: this.userService.getFilters(), - range: this.user?.settings?.dateRange + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ chart, firstOrderDate, performance }) => { @@ -298,13 +310,16 @@ export class GfAnalysisPageComponent implements OnInit { valueInPercentage, valueWithCurrencyEffect } - ] of chart.entries()) { + ] of (chart ?? []).entries()) { + // Ignore first item where value is 0 if (index > 0 || this.user?.settings?.dateRange === 'max') { - // Ignore first item where value is 0 - this.investments.push({ - date, - investment: totalInvestmentValueWithCurrencyEffect - }); + if (totalInvestmentValueWithCurrencyEffect !== undefined) { + this.investments.push({ + date, + investment: totalInvestmentValueWithCurrencyEffect + }); + } + this.performanceDataItems.push({ date, value: isNumber(valueWithCurrencyEffect) @@ -320,7 +335,7 @@ export class GfAnalysisPageComponent implements OnInit { } if ( - this.deviceType === 'mobile' && + this.deviceType() === 'mobile' && this.performance.currentValueInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES ) { @@ -387,7 +402,7 @@ export class GfAnalysisPageComponent implements OnInit { dataSource, symbol, filters: this.userService.getFilters(), - range: this.user?.settings?.dateRange, + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE, startDate: this.firstOrderDate }) .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 4c5c61bd8..ec90fccec 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -310,13 +310,15 @@ -
{{ holding.name }}
+
+ {{ holding.assetProfile.name }} +
-
{{ holding.name }}
+
+ {{ holding.assetProfile.name }} +
& { + [symbol: string]: Pick< + PortfolioPosition['assetProfile'], + 'currency' | 'name' + > & { value: number; }; }; 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/locales/messages.uk.xlf b/apps/client/src/locales/messages.uk.xlf index edc28f604..94dc7e52a 100644 --- a/apps/client/src/locales/messages.uk.xlf +++ b/apps/client/src/locales/messages.uk.xlf @@ -292,7 +292,7 @@ please - please + будь ласка apps/client/src/app/pages/pricing/pricing-page.html 333 @@ -360,7 +360,7 @@ with - with + з apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html 87 @@ -1388,7 +1388,7 @@ By - By + До apps/client/src/app/pages/portfolio/fire/fire-page.html 139 @@ -1892,7 +1892,7 @@ Indonesia - Indonesia + Індонезія libs/ui/src/lib/i18n.ts 90 @@ -2108,7 +2108,7 @@ Code - Code + Код apps/client/src/app/components/admin-overview/admin-overview.html 159 @@ -2636,7 +2636,7 @@ Argentina - Argentina + Аргентина libs/ui/src/lib/i18n.ts 78 @@ -3204,7 +3204,7 @@ for - for + для apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html 128 @@ -3560,7 +3560,7 @@ Duration - Duration + Тривалість apps/client/src/app/components/admin-overview/admin-overview.html 172 @@ -4897,7 +4897,7 @@ here - here + тут apps/client/src/app/pages/pricing/pricing-page.html 347 @@ -5113,7 +5113,7 @@ Our official Ghostfolio Premium cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover operational costs for the hosting infrastructure and professional data providers, and to fund ongoing development. - Наша офіційна хмарна пропозиція Ghostfolio Premium - це найпростіший спосіб почати роботу. Завдяки економії часу, це буде найкращим варіантом для більшості людей. Доходи використовуються для покриття витрат на хостинг-інфраструктуру та фінансування постійної розробки. + Наша офіційна хмарна пропозиція Ghostfolio Premium - це найпростіший спосіб почати роботу. Завдяки економії часу, це буде найкращим варіантом для більшості людей. Доходи використовуються для покриття витрат на хостинг-інфраструктуру та фінансування постійної розробки. apps/client/src/app/pages/pricing/pricing-page.html 7 @@ -6392,7 +6392,7 @@ Loan - Loan + Позика libs/ui/src/lib/i18n.ts 58 @@ -6944,7 +6944,7 @@ Role - Role + Роль apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html 39 @@ -7080,7 +7080,7 @@ Authentication - Authentication + Автентифікація apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html 60 @@ -7476,7 +7476,7 @@ Lazy - Lazy + Лінивий apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts 235 @@ -7484,7 +7484,7 @@ Instant - Instant + Миттєвий apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts 239 @@ -7500,7 +7500,7 @@ Mode - Mode + Режим apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 519 @@ -7508,7 +7508,7 @@ Selector - Selector + Селектор apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 535 @@ -7532,7 +7532,7 @@ real-time - real-time + реальний час apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts 239 @@ -7548,7 +7548,7 @@ Create - Create + Створити libs/ui/src/lib/tags-selector/tags-selector.component.html 50 @@ -7556,7 +7556,7 @@ Change - Change + Змінити libs/ui/src/lib/holdings-table/holdings-table.component.html 138 @@ -7568,7 +7568,7 @@ Performance - Performance + Дохідність apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html 6 @@ -7624,7 +7624,7 @@ Armenia - Armenia + Вірменія libs/ui/src/lib/i18n.ts 77 @@ -7640,7 +7640,7 @@ Singapore - Singapore + Сінгапур libs/ui/src/lib/i18n.ts 97 @@ -7672,7 +7672,7 @@ Continue - Continue + Продовжити apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html 57 @@ -7732,7 +7732,7 @@ terms-of-service - terms-of-service + umovy-nadannia-posluh kebab-case libs/common/src/lib/routes/routes.ts @@ -7781,7 +7781,7 @@ Apply - Apply + Застосувати apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 154 @@ -7837,7 +7837,7 @@ someone - someone + когось apps/client/src/app/pages/public/public-page.component.ts 62 @@ -7853,7 +7853,7 @@ Watchlist - Watchlist + Список спостереження apps/client/src/app/components/home-watchlist/home-watchlist.html 4 @@ -7897,7 +7897,7 @@ changelog - changelog + zhurnal-zmin kebab-case libs/common/src/lib/routes/routes.ts @@ -8030,7 +8030,7 @@ personal-finance-tools - personal-finance-tools + instrumenty-osobystykh-finansiv kebab-case libs/common/src/lib/routes/routes.ts @@ -8047,7 +8047,7 @@ markets - markets + rynky kebab-case libs/common/src/lib/routes/routes.ts @@ -8108,7 +8108,7 @@ Available - Available + Доступно apps/client/src/app/components/data-provider-status/data-provider-status.component.html 3 @@ -8116,7 +8116,7 @@ Unavailable - Unavailable + Недоступно apps/client/src/app/components/data-provider-status/data-provider-status.component.html 5 @@ -8132,7 +8132,7 @@ new - new + новий apps/client/src/app/components/admin-settings/admin-settings.component.html 79 @@ -8140,7 +8140,7 @@ Investment - Investment + Інвестиція apps/client/src/app/pages/i18n/i18n-page.html 15 @@ -8164,7 +8164,7 @@ Equity - Equity + Акції apps/client/src/app/pages/i18n/i18n-page.html 41 @@ -8252,7 +8252,7 @@ Investment - Investment + Інвестиція apps/client/src/app/pages/i18n/i18n-page.html 95 @@ -8276,7 +8276,7 @@ start - start + pochatok kebab-case libs/common/src/lib/routes/routes.ts @@ -8297,7 +8297,7 @@ Generate - Generate + Згенерувати apps/client/src/app/components/user-account-access/user-account-access.html 45 @@ -8313,7 +8313,7 @@ Stocks - Stocks + Акції apps/client/src/app/components/markets/markets.component.ts 51 @@ -8325,7 +8325,7 @@ Cryptocurrencies - Cryptocurrencies + Криптовалюти apps/client/src/app/components/markets/markets.component.ts 52 @@ -8361,7 +8361,7 @@ Collectible - Collectible + Колекційний предмет libs/ui/src/lib/i18n.ts 55 @@ -8421,7 +8421,7 @@ Fees - Fees + Комісії apps/client/src/app/pages/i18n/i18n-page.html 161 @@ -8429,7 +8429,7 @@ Liquidity - Liquidity + Ліквідність apps/client/src/app/pages/i18n/i18n-page.html 70 @@ -8565,7 +8565,7 @@ Asia-Pacific - Asia-Pacific + Азіатсько-Тихоокеанський регіон apps/client/src/app/pages/i18n/i18n-page.html 165 @@ -8629,7 +8629,7 @@ Europe - Europe + Європа apps/client/src/app/pages/i18n/i18n-page.html 195 @@ -8661,7 +8661,7 @@ Japan - Japan + Японія apps/client/src/app/pages/i18n/i18n-page.html 209 diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 1eb5bd2dd..045de2eb6 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -1,10 +1,11 @@ @use '@angular/material' as mat; +@use 'sass:color'; -@import './styles/bootstrap'; -@import './styles/table'; -@import './styles/variables'; +@use './styles/bootstrap'; +@use './styles/table' as table; +@use './styles/variables' as variables; -@import 'svgmap/style.min'; +@use 'svgmap/style.min'; :root { --dark-background: rgb(25, 25, 25); @@ -12,8 +13,10 @@ --light-background: rgb(255, 255, 255); --dark-primary-text: - #{red($dark-primary-text)}, #{green($dark-primary-text)}, - #{blue($dark-primary-text)}, #{alpha($dark-primary-text)}; + #{color.channel(variables.$dark-primary-text, 'red')}, + #{color.channel(variables.$dark-primary-text, 'green')}, + #{color.channel(variables.$dark-primary-text, 'blue')}, + #{color.channel(variables.$dark-primary-text, 'alpha')}; --dark-secondary-text: 0, 0, 0, 0.54; --dark-accent-text: 0, 0, 0, 0.87; --dark-warn-text: 0, 0, 0, 0.87; @@ -21,8 +24,10 @@ --dark-dividers: 0, 0, 0, 0.12; --dark-focused: 0, 0, 0, 0.12; --light-primary-text: - #{red($light-primary-text)}, #{green($light-primary-text)}, - #{blue($light-primary-text)}, #{alpha($light-primary-text)}; + #{color.channel(variables.$light-primary-text, 'red')}, + #{color.channel(variables.$light-primary-text, 'green')}, + #{color.channel(variables.$light-primary-text, 'blue')}, + #{color.channel(variables.$light-primary-text, 'alpha')}; --light-secondary-text: 255, 255, 255, 0.7; --light-accent-text: 255, 255, 255, 1; --light-warn-text: 255, 255, 255, 1; @@ -240,7 +245,7 @@ body { } .gf-table { - @include gf-table(true); + @include table.gf-table(true); } .mat-mdc-dialog-container { @@ -353,17 +358,13 @@ ngx-skeleton-loader { } .gf-table { - @include gf-table; + @include table.gf-table; } .gf-text-wrap-balance { 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 +485,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/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 113dffe4a..28d902d71 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -256,6 +256,7 @@ export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; export const PROPERTY_UPTIME = 'UPTIME'; +export const PROPERTY_WEB_FETCH_ROUTES = 'WEB_FETCH_ROUTES'; export const QUEUE_JOB_STATUS_LIST = [ 'active', diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index c5f6cbbb9..02bd26b90 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -1,5 +1,12 @@ import { NumberParser } from '@internationalized/number'; -import { Type as ActivityType, DataSource, MarketData } from '@prisma/client'; +import { + Type as ActivityType, + DataSource, + MarketData, + Prisma, + SymbolProfile, + SymbolProfileOverrides +} from '@prisma/client'; import { Big } from 'big.js'; import { isISO4217CurrencyCode } from 'class-validator'; import { @@ -47,6 +54,42 @@ export const DATE_FORMAT = 'yyyy-MM-dd'; export const DATE_FORMAT_MONTHLY = 'MMMM yyyy'; export const DATE_FORMAT_YEARLY = 'yyyy'; +export function applyAssetProfileOverrides>( + assetProfile: T, + assetProfileOverrides: SymbolProfileOverrides | null +): T { + if (!assetProfileOverrides) { + return assetProfile; + } + + const assetProfileWithOverrides = { ...assetProfile } as T; + + assetProfileWithOverrides.assetClass = + assetProfileOverrides.assetClass ?? assetProfile.assetClass; + + assetProfileWithOverrides.assetSubClass = + assetProfileOverrides.assetSubClass ?? assetProfile.assetSubClass; + + if ((assetProfileOverrides.countries as Prisma.JsonArray)?.length > 0) { + assetProfileWithOverrides.countries = assetProfileOverrides.countries; + } + + if ((assetProfileOverrides.holdings as Prisma.JsonArray)?.length > 0) { + assetProfileWithOverrides.holdings = assetProfileOverrides.holdings; + } + + assetProfileWithOverrides.name = + assetProfileOverrides.name ?? assetProfile.name; + + if ((assetProfileOverrides.sectors as Prisma.JsonArray)?.length > 0) { + assetProfileWithOverrides.sectors = assetProfileOverrides.sectors; + } + + assetProfileWithOverrides.url = assetProfileOverrides.url ?? assetProfile.url; + + return assetProfileWithOverrides; +} + export function calculateBenchmarkTrend({ days, historicalData diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index c4ef2e3dc..c94a1efa5 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -1,22 +1,12 @@ import { Market, MarketAdvanced } from '@ghostfolio/common/types'; -import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client'; +import { Tag } from '@prisma/client'; -import { Country } from './country.interface'; import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; -import { Holding } from './holding.interface'; -import { Sector } from './sector.interface'; export interface PortfolioPosition { activitiesCount: number; allocationInPercentage: number; - - /** @deprecated */ - assetClass?: AssetClass; - - /** @deprecated */ - assetClassLabel?: string; - assetProfile: Pick< EnhancedSymbolProfile, | 'assetClass' @@ -33,22 +23,6 @@ export interface PortfolioPosition { assetClassLabel?: string; assetSubClassLabel?: string; }; - - /** @deprecated */ - assetSubClass?: AssetSubClass; - - /** @deprecated */ - assetSubClassLabel?: string; - - /** @deprecated */ - countries: Country[]; - - /** @deprecated */ - currency: string; - - /** @deprecated */ - dataSource: DataSource; - dateOfFirstActivity: Date; dividend: number; exchange?: string; @@ -56,38 +30,19 @@ export interface PortfolioPosition { grossPerformancePercent: number; grossPerformancePercentWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number; - - /** @deprecated */ - holdings: Holding[]; - investment: number; marketChange?: number; marketChangePercent?: number; marketPrice: number; markets?: { [key in Market]: number }; marketsAdvanced?: { [key in MarketAdvanced]: number }; - - /** @deprecated */ - name: string; - netPerformance: number; netPerformancePercent: number; netPerformancePercentWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number; quantity: number; - - /** @deprecated */ - sectors: Sector[]; - - /** @deprecated */ - symbol: string; - tags?: Tag[]; type?: string; - - /** @deprecated */ - url?: string; - valueInBaseCurrency?: number; valueInPercentage?: number; } diff --git a/libs/common/src/lib/interfaces/product.ts b/libs/common/src/lib/interfaces/product.ts index 5ef023ff8..6cd88fbe8 100644 --- a/libs/common/src/lib/interfaces/product.ts +++ b/libs/common/src/lib/interfaces/product.ts @@ -13,5 +13,6 @@ export interface Product { pricingPerYear?: string; regions?: string[]; slogan?: string; + url?: string; useAnonymously?: boolean; } diff --git a/libs/common/src/lib/personal-finance-tools.ts b/libs/common/src/lib/personal-finance-tools.ts index 063b4254c..23697e63b 100644 --- a/libs/common/src/lib/personal-finance-tools.ts +++ b/libs/common/src/lib/personal-finance-tools.ts @@ -7,7 +7,8 @@ export const personalFinanceTools: Product[] = [ key: 'allinvestview', languages: ['English'], name: 'AllInvestView', - slogan: 'All your Investments in One View' + slogan: 'All your Investments in One View', + url: 'https://www.allinvestview.com' }, { founded: 2019, @@ -16,14 +17,16 @@ export const personalFinanceTools: Product[] = [ key: 'allvue-systems', name: 'Allvue Systems', origin: 'United States', - slogan: 'Investment Software Suite' + slogan: 'Investment Software Suite', + url: 'https://www.allvuesystems.com' }, { founded: 2016, key: 'alphatrackr', languages: ['English'], name: 'AlphaTrackr', - slogan: 'Investment Portfolio Tracking Tool' + slogan: 'Investment Portfolio Tracking Tool', + url: 'https://www.alphatrackr.com' }, { founded: 2017, @@ -31,7 +34,8 @@ export const personalFinanceTools: Product[] = [ key: 'altoo', name: 'Altoo Wealth Platform', origin: 'Switzerland', - slogan: 'Simplicity for Complex Wealth' + slogan: 'Simplicity for Complex Wealth', + url: 'https://altoo.io' }, { founded: 2018, @@ -40,7 +44,8 @@ export const personalFinanceTools: Product[] = [ key: 'altruist', name: 'Altruist', origin: 'United States', - slogan: 'The wealth platform built for independent advisors' + slogan: 'The wealth platform built for independent advisors', + url: 'https://altruist.com' }, { founded: 2023, @@ -50,7 +55,8 @@ export const personalFinanceTools: Product[] = [ name: 'Amsflow Portfolio', origin: 'Singapore', pricingPerYear: '$228', - slogan: 'Portfolio Visualizer' + slogan: 'Portfolio Visualizer', + url: 'https://amsflow.com' }, { founded: 2018, @@ -61,7 +67,8 @@ export const personalFinanceTools: Product[] = [ name: 'Anlage.App', origin: 'Austria', pricingPerYear: '$120', - slogan: 'Analyze and track your portfolio.' + slogan: 'Analyze and track your portfolio.', + url: 'https://anlage.app' }, { founded: 2022, @@ -70,14 +77,16 @@ export const personalFinanceTools: Product[] = [ languages: ['English'], name: 'Asseta', origin: 'United States', - slogan: 'The Intelligent Family Office Suite' + slogan: 'The Intelligent Family Office Suite', + url: 'https://www.asseta.ai' }, { founded: 2016, key: 'atominvest', name: 'Atominvest', origin: 'United Kingdom', - slogan: 'Portfolio Management' + slogan: 'Portfolio Management', + url: 'https://www.atominvest.co' }, { founded: 2020, @@ -87,7 +96,8 @@ export const personalFinanceTools: Product[] = [ name: 'Balance Pro', origin: 'United States', pricingPerYear: '$47.99', - slogan: 'The Smarter Way to Track Your Finances' + slogan: 'The Smarter Way to Track Your Finances', + url: 'https://www.balancepro.app' }, { hasFreePlan: false, @@ -96,7 +106,8 @@ export const personalFinanceTools: Product[] = [ name: 'Banktivity', origin: 'United States', pricingPerYear: '$59.99', - slogan: 'Proactive money management app for macOS & iOS' + slogan: 'Proactive money management app for macOS & iOS', + url: 'https://www.banktivity.com' }, { founded: 2022, @@ -104,7 +115,8 @@ export const personalFinanceTools: Product[] = [ hasSelfHostingAbility: false, key: 'basil-finance', name: 'Basil Finance', - slogan: 'The ultimate solution for tracking and managing your investments' + slogan: 'The ultimate solution for tracking and managing your investments', + url: 'https://basil.fi' }, { founded: 2020, @@ -114,7 +126,8 @@ export const personalFinanceTools: Product[] = [ name: 'Beanvest', origin: 'France', pricingPerYear: '$100', - slogan: 'Stock Portfolio Tracker for Smart Investors' + slogan: 'Stock Portfolio Tracker for Smart Investors', + url: 'https://beanvest.com' }, { founded: 2024, @@ -123,7 +136,8 @@ export const personalFinanceTools: Product[] = [ languages: ['Deutsch', 'English', 'Français', 'Italiano'], name: 'BlueBudget', origin: 'Switzerland', - slogan: 'Schweizer Budget App für einfache & smarte Budgetplanung' + slogan: 'Schweizer Budget App für einfache & smarte Budgetplanung', + url: 'https://www.bluebudget.ch' }, { founded: 2015, @@ -134,13 +148,15 @@ export const personalFinanceTools: Product[] = [ note: 'Originally named as NewRetirement', origin: 'United States', pricingPerYear: '$144', - slogan: 'Take control with retirement planning tools that begin with you' + slogan: 'Take control with retirement planning tools that begin with you', + url: 'https://www.boldin.com' }, { key: 'budgetpulse', name: 'BudgetPulse', origin: 'United States', - slogan: 'Giving life to your finance!' + slogan: 'Giving life to your finance!', + url: 'https://www.budgetpulse.com' }, { founded: 2007, @@ -151,7 +167,8 @@ export const personalFinanceTools: Product[] = [ origin: 'United States', pricingPerYear: '$48', regions: ['Global'], - slogan: 'Take control of your financial future' + slogan: 'Take control of your financial future', + url: 'https://www.buxfer.com' }, { hasFreePlan: true, @@ -160,7 +177,8 @@ export const personalFinanceTools: Product[] = [ name: 'Capitally', origin: 'Poland', pricingPerYear: '€80', - slogan: 'Optimize your investments performance' + slogan: 'Optimize your investments performance', + url: 'https://www.mycapitally.com' }, { founded: 2022, @@ -185,7 +203,8 @@ export const personalFinanceTools: Product[] = [ key: 'cobalt', name: 'Cobalt', origin: 'United States', - slogan: 'Next-Level Portfolio Monitoring' + slogan: 'Next-Level Portfolio Monitoring', + url: 'https://www.cobalt.pe' }, { founded: 2017, @@ -195,7 +214,8 @@ export const personalFinanceTools: Product[] = [ name: 'CoinStats', origin: 'Armenia', pricingPerYear: '$168', - slogan: 'Manage All Your Wallets & Exchanges From One Place' + slogan: 'Manage All Your Wallets & Exchanges From One Place', + url: 'https://coinstats.app' }, { founded: 2013, @@ -206,14 +226,16 @@ export const personalFinanceTools: Product[] = [ name: 'CoinTracking', origin: 'Germany', pricingPerYear: '$120', - slogan: 'The leading Crypto Portfolio Tracker & Tax Calculator' + slogan: 'The leading Crypto Portfolio Tracker & Tax Calculator', + url: 'https://cointracking.info' }, { founded: 2019, key: 'compound-planning', name: 'Compound Planning', origin: 'United States', - slogan: 'Modern Wealth & Investment Management' + slogan: 'Modern Wealth & Investment Management', + url: 'https://compoundplanning.com' }, { founded: 2019, @@ -223,7 +245,8 @@ export const personalFinanceTools: Product[] = [ name: 'Copilot Money', origin: 'United States', pricingPerYear: '$95', - slogan: 'Do money better with Copilot' + slogan: 'Do money better with Copilot', + url: 'https://www.copilot.money' }, { founded: 2014, @@ -232,7 +255,8 @@ export const personalFinanceTools: Product[] = [ name: 'CountAbout', origin: 'United States', pricingPerYear: '$9.99', - slogan: 'Customizable and Secure Personal Finance App' + slogan: 'Customizable and Secure Personal Finance App', + url: 'https://countabout.com' }, { founded: 2023, @@ -240,14 +264,16 @@ export const personalFinanceTools: Product[] = [ key: 'danti', name: 'Danti', origin: 'United Kingdom', - slogan: 'Digitising Generational Wealth' + slogan: 'Digitising Generational Wealth', + url: 'https://danti.io' }, { founded: 2020, key: 'de.fi', languages: ['English'], name: 'De.Fi', - slogan: 'DeFi Portfolio Tracker' + slogan: 'DeFi Portfolio Tracker', + url: 'https://de.fi' }, { founded: 2016, @@ -258,7 +284,8 @@ export const personalFinanceTools: Product[] = [ name: 'DeFi Portfolio Tracker by Zerion', origin: 'United States', pricingPerYear: '$99', - slogan: 'DeFi Portfolio Tracker for All Chains' + slogan: 'DeFi Portfolio Tracker for All Chains', + url: 'https://zerion.io/defi-portfolio-tracker' }, { founded: 2022, @@ -269,7 +296,8 @@ export const personalFinanceTools: Product[] = [ name: 'DEGIRO Portfolio Tracker by Capitalyse', origin: 'Netherlands', pricingPerYear: '€24', - slogan: 'Democratizing Data Analytics' + slogan: 'Democratizing Data Analytics', + url: 'https://capitalyse.app/app/degiro' }, { founded: 2017, @@ -280,7 +308,8 @@ export const personalFinanceTools: Product[] = [ note: 'Acquired by eToro', origin: 'Belgium', pricingPerYear: '$150', - slogan: 'The app to track all your investments. Make smart moves only.' + slogan: 'The app to track all your investments. Make smart moves only.', + url: 'https://delta.app' }, { hasFreePlan: true, @@ -289,7 +318,8 @@ export const personalFinanceTools: Product[] = [ languages: ['English'], name: 'Digrin', pricingPerYear: '$49.90', - slogan: 'Dividend Portfolio Tracker' + slogan: 'Dividend Portfolio Tracker', + url: 'https://www.digrin.com' }, { founded: 2019, @@ -300,7 +330,8 @@ export const personalFinanceTools: Product[] = [ name: 'DivvyDiary', origin: 'Germany', pricingPerYear: '€65', - slogan: 'Your personal Dividend Calendar' + slogan: 'Your personal Dividend Calendar', + url: 'https://divvydiary.com' }, { founded: 2009, @@ -309,7 +340,8 @@ export const personalFinanceTools: Product[] = [ name: 'Empower', note: 'Originally named as Personal Capital', origin: 'United States', - slogan: 'Get answers to your money questions' + slogan: 'Get answers to your money questions', + url: 'https://www.empower.com' }, { alias: '8figures', @@ -317,7 +349,8 @@ export const personalFinanceTools: Product[] = [ key: 'eightfigures', name: '8FIGURES', origin: 'United States', - slogan: 'Portfolio Tracker Designed by Professional Investors' + slogan: 'Portfolio Tracker Designed by Professional Investors', + url: 'https://8figures.com' }, { founded: 2010, @@ -325,7 +358,8 @@ export const personalFinanceTools: Product[] = [ key: 'etops', name: 'etops', origin: 'Switzerland', - slogan: 'Your financial superpower' + slogan: 'Your financial superpower', + url: 'https://www.etops.com' }, { founded: 2020, @@ -335,7 +369,8 @@ export const personalFinanceTools: Product[] = [ name: 'Exirio', origin: 'United States', pricingPerYear: '$100', - slogan: 'All your wealth, in one place.' + slogan: 'All your wealth, in one place.', + url: 'https://www.exirio.com' }, { founded: 2018, @@ -345,7 +380,8 @@ export const personalFinanceTools: Product[] = [ name: 'Fey', origin: 'Canada', pricingPerYear: '$300', - slogan: 'Make better investments.' + slogan: 'Make better investments.', + url: 'https://fey.com' }, { founded: 2023, @@ -356,7 +392,8 @@ export const personalFinanceTools: Product[] = [ name: 'Fina', origin: 'United States', pricingPerYear: '$115', - slogan: 'Flexible Financial Management' + slogan: 'Flexible Financial Management', + url: 'https://www.fina.money' }, { founded: 2023, @@ -366,7 +403,8 @@ export const personalFinanceTools: Product[] = [ name: 'Finanzfluss Copilot', origin: 'Germany', pricingPerYear: '€69.99', - slogan: 'Portfolio Tracker für dein Vermögen' + slogan: 'Portfolio Tracker für dein Vermögen', + url: 'https://www.finanzfluss.de/copilot' }, { founded: 2020, @@ -374,7 +412,8 @@ export const personalFinanceTools: Product[] = [ languages: ['Deutsch', 'English', 'Français'], name: 'Finary', origin: 'United States', - slogan: 'Real-Time Portfolio Tracker & Stock Tracker' + slogan: 'Real-Time Portfolio Tracker & Stock Tracker', + url: 'https://finary.com' }, { founded: 2021, @@ -385,14 +424,16 @@ export const personalFinanceTools: Product[] = [ name: 'FINATEKA', origin: 'United States', slogan: - 'The most convenient mobile application for personal finance accounting' + 'The most convenient mobile application for personal finance accounting', + url: 'https://finateka.com' }, { founded: 2022, key: 'fincake', name: 'Fincake', origin: 'British Virgin Islands', - slogan: 'Easy-to-use Portfolio Tracker' + slogan: 'Easy-to-use Portfolio Tracker', + url: 'https://fincake.io' }, { founded: 2021, @@ -400,7 +441,8 @@ export const personalFinanceTools: Product[] = [ key: 'finvest', name: 'Finvest', origin: 'United States', - slogan: 'Grow your wealth in a stress-free way' + slogan: 'Grow your wealth in a stress-free way', + url: 'https://www.getfinvest.com' }, { founded: 2023, @@ -409,7 +451,8 @@ export const personalFinanceTools: Product[] = [ name: 'FinWise', origin: 'South Africa', pricingPerYear: '€69.99', - slogan: 'Personal finances, simplified' + slogan: 'Personal finances, simplified', + url: 'https://finwiseapp.io' }, { founded: 2021, @@ -420,7 +463,8 @@ export const personalFinanceTools: Product[] = [ name: 'FIREkit', origin: 'Ukraine', pricingPerYear: '$40', - slogan: 'A simple solution to track your wealth online' + slogan: 'A simple solution to track your wealth online', + url: 'https://firekit.space' }, { hasFreePlan: true, @@ -430,7 +474,8 @@ export const personalFinanceTools: Product[] = [ name: 'folishare', origin: 'Austria', pricingPerYear: '$65', - slogan: 'Take control over your investments' + slogan: 'Take control over your investments', + url: 'https://www.folishare.com' }, { hasFreePlan: true, @@ -448,7 +493,8 @@ export const personalFinanceTools: Product[] = [ origin: 'Argentina', pricingPerYear: '$60', regions: ['Global'], - slogan: 'Take control of your finances from WhatsApp' + slogan: 'Take control of your finances from WhatsApp', + url: 'https://gasti.pro' }, { founded: 2020, @@ -459,7 +505,8 @@ export const personalFinanceTools: Product[] = [ name: 'getquin', origin: 'Germany', pricingPerYear: '€48', - slogan: 'Portfolio Tracker, Analysis & Community' + slogan: 'Portfolio Tracker, Analysis & Community', + url: 'https://www.getquin.com' }, { hasFreePlan: true, @@ -471,6 +518,18 @@ export const personalFinanceTools: Product[] = [ origin: 'Germany', slogan: 'Volle Kontrolle über deine Investitionen' }, + { + founded: 2024, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'gustav', + languages: ['Français'], + name: 'Gustav', + origin: 'France', + pricingPerYear: '€59.99', + slogan: 'Prenez enfin le contrôle de votre argent', + url: 'https://get-gustav.com' + }, { hasFreePlan: true, hasSelfHostingAbility: false, @@ -479,6 +538,7 @@ export const personalFinanceTools: Product[] = [ name: 'Holistic', origin: 'Germany', slogan: 'Die All-in-One Lösung für dein Vermögen.', + url: 'https://holistic.capital', useAnonymously: true }, { @@ -487,7 +547,8 @@ export const personalFinanceTools: Product[] = [ key: 'honeydue', name: 'Honeydue', origin: 'United States', - slogan: 'Finance App for Couples' + slogan: 'Finance App for Couples', + url: 'https://www.honeydue.com' }, { founded: 2022, @@ -515,7 +576,8 @@ export const personalFinanceTools: Product[] = [ key: 'investify', name: 'Investify', origin: 'Pakistan', - slogan: 'Advanced portfolio tracking and stock market information' + slogan: 'Advanced portfolio tracking and stock market information', + url: 'https://www.investify.pk' }, { founded: 2021, @@ -527,6 +589,7 @@ export const personalFinanceTools: Product[] = [ origin: 'Switzerland', pricingPerYear: '$156', slogan: 'Track all your assets, investments and portfolios in one place', + url: 'https://invmon.com', useAnonymously: true }, { @@ -537,7 +600,8 @@ export const personalFinanceTools: Product[] = [ name: 'justETF', origin: 'Germany', pricingPerYear: '€119', - slogan: 'ETF portfolios made simple' + slogan: 'ETF portfolios made simple', + url: 'https://www.justetf.com' }, { founded: 2018, @@ -546,7 +610,8 @@ export const personalFinanceTools: Product[] = [ key: 'koinly', name: 'Koinly', origin: 'Singapore', - slogan: 'Track all your crypto wallets in one place' + slogan: 'Track all your crypto wallets in one place', + url: 'https://koinly.io' }, { founded: 2016, @@ -556,7 +621,8 @@ export const personalFinanceTools: Product[] = [ name: 'Koyfin', origin: 'United States', pricingPerYear: '$468', - slogan: 'Comprehensive financial data analysis' + slogan: 'Comprehensive financial data analysis', + url: 'https://www.koyfin.com' }, { founded: 2019, @@ -566,7 +632,8 @@ export const personalFinanceTools: Product[] = [ name: 'Kubera®', origin: 'United States', pricingPerYear: '$249', - slogan: 'The Time Machine for your Net Worth' + slogan: 'The Time Machine for your Net Worth', + url: 'https://www.kubera.com' }, { founded: 2021, @@ -575,7 +642,8 @@ export const personalFinanceTools: Product[] = [ languages: ['Deutsch', 'English'], name: 'Leafs', origin: 'Switzerland', - slogan: 'Sustainability insights for wealth managers' + slogan: 'Sustainability insights for wealth managers', + url: 'https://leafs.ch' }, { founded: 2018, @@ -585,7 +653,8 @@ export const personalFinanceTools: Product[] = [ name: 'Magnifi', origin: 'United States', pricingPerYear: '$132', - slogan: 'AI Investing Assistant' + slogan: 'AI Investing Assistant', + url: 'https://magnifi.com' }, { founded: 2022, @@ -597,14 +666,16 @@ export const personalFinanceTools: Product[] = [ origin: 'Germany', pricingPerYear: '€168', regions: ['Global'], - slogan: 'Track your investments' + slogan: 'Track your investments', + url: 'https://markets.sh' }, { founded: 2010, key: 'masttro', name: 'Masttro', origin: 'United States', - slogan: 'Your platform for wealth in full view' + slogan: 'Your platform for wealth in full view', + url: 'https://masttro.com' }, { founded: 2021, @@ -619,7 +690,8 @@ export const personalFinanceTools: Product[] = [ origin: 'United States', pricingPerYear: '$145', regions: ['United States'], - slogan: 'Your financial future, in your control' + slogan: 'Your financial future, in your control', + url: 'https://github.com/maybe-finance/maybe' }, { hasFreePlan: false, @@ -630,7 +702,8 @@ export const personalFinanceTools: Product[] = [ origin: 'United States', pricingPerYear: '$204', regions: ['Canada', 'United States'], - slogan: 'The smartest way to track your crypto' + slogan: 'The smartest way to track your crypto', + url: 'https://www.merlincrypto.com' }, { founded: 1991, @@ -649,7 +722,8 @@ export const personalFinanceTools: Product[] = [ name: 'Monarch Money', origin: 'United States', pricingPerYear: '$99.99', - slogan: 'The modern way to manage your money' + slogan: 'The modern way to manage your money', + url: 'https://www.monarch.com' }, { founded: 1999, @@ -659,7 +733,8 @@ export const personalFinanceTools: Product[] = [ name: 'Moneydance', origin: 'Scotland', pricingPerYear: '$100', - slogan: 'Personal Finance Manager for Mac, Windows, and Linux' + slogan: 'Personal Finance Manager for Mac, Windows, and Linux', + url: 'https://moneydance.com' }, { hasFreePlan: true, @@ -668,7 +743,8 @@ export const personalFinanceTools: Product[] = [ name: 'Money Peak', note: 'Originally named as goSPATZ', origin: 'Germany', - slogan: 'Dein smarter Finance Assistant' + slogan: 'Dein smarter Finance Assistant', + url: 'https://moneypeak.ai' }, { founded: 2007, @@ -677,14 +753,16 @@ export const personalFinanceTools: Product[] = [ note: 'License is a perpetual license', origin: 'United States', pricingPerYear: '$59.99', - slogan: 'Have total control of your financial life' + slogan: 'Have total control of your financial life', + url: 'https://www.moneyspire.com' }, { key: 'moneywiz', name: 'MoneyWiz', origin: 'United States', pricingPerYear: '$29.99', - slogan: 'Get money management superpowers' + slogan: 'Get money management superpowers', + url: 'https://www.wiz.money' }, { hasFreePlan: false, @@ -692,7 +770,8 @@ export const personalFinanceTools: Product[] = [ key: 'monse', name: 'Monse', pricingPerYear: '$60', - slogan: 'Gain financial control and keep your data private.' + slogan: 'Gain financial control and keep your data private.', + url: 'https://monse.app' }, { founded: 2025, @@ -703,7 +782,8 @@ export const personalFinanceTools: Product[] = [ name: 'Monsy', origin: 'Indonesia', pricingPerYear: '$20', - slogan: 'Smart, simple, stress-free money tracking.' + slogan: 'Smart, simple, stress-free money tracking.', + url: 'https://www.monsy.app' }, { hasFreePlan: true, @@ -713,7 +793,18 @@ export const personalFinanceTools: Product[] = [ name: 'Morningstar® Portfolio Manager', origin: 'United States', slogan: - 'Track your equity, fund, investment trust, ETF and pension investments in one place.' + 'Track your equity, fund, investment trust, ETF and pension investments in one place.', + url: 'https://www.morningstar.com/mm' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'myfinancetools', + languages: ['Deutsch', 'English', 'Español', 'Français', 'Português'], + name: 'MyFinanceTools', + pricingPerYear: '$36', + slogan: 'Your Personal Finance Command Center', + url: 'https://myfinancetools.io' }, { founded: 2020, @@ -723,7 +814,8 @@ export const personalFinanceTools: Product[] = [ name: 'Crypto Portfolio Tracker by Nansen', origin: 'Singapore', pricingPerYear: '$1188', - slogan: 'Your Complete Crypto Portfolio, Reimagined' + slogan: 'Your Complete Crypto Portfolio, Reimagined', + url: 'https://www.nansen.ai/crypto-portfolio-tracker' }, { founded: 2017, @@ -733,7 +825,17 @@ export const personalFinanceTools: Product[] = [ name: 'Navexa', origin: 'Australia', pricingPerYear: '$90', - slogan: 'The Intelligent Portfolio Tracker' + slogan: 'The Intelligent Portfolio Tracker', + url: 'https://www.navexa.com' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'networthy', + name: 'Networthy', + pricingPerYear: '€49.99', + slogan: 'Your Personal Financial Analyst, powered by AI.', + url: 'https://networthy.pro' }, { founded: 2020, @@ -745,21 +847,24 @@ export const personalFinanceTools: Product[] = [ origin: 'Germany', pricingPerYear: '€99.99', regions: ['Austria', 'Germany', 'Switzerland'], - slogan: 'Dein Vermögen immer im Blick' + slogan: 'Dein Vermögen immer im Blick', + url: 'https://www.parqet.com' }, { hasSelfHostingAbility: false, key: 'peek', name: 'Peek', origin: 'Singapore', - slogan: 'Feel in control of your money without spreadsheets or shame' + slogan: 'Feel in control of your money without spreadsheets or shame', + url: 'https://peek.money' }, { key: 'pennies', name: 'Pennies', origin: 'United States', pricingPerYear: '$39.99', - slogan: 'Your money. Made simple.' + slogan: 'Your money. Made simple.', + url: 'https://www.getpennies.com' }, { founded: 2022, @@ -769,7 +874,8 @@ export const personalFinanceTools: Product[] = [ name: 'PinkLion', origin: 'Germany', pricingPerYear: '€50', - slogan: 'Invest smarter, not harder' + slogan: 'Invest smarter, not harder', + url: 'https://pinklion.xyz' }, { founded: 2023, @@ -780,7 +886,8 @@ export const personalFinanceTools: Product[] = [ name: 'Plainzer', origin: 'Poland', pricingPerYear: '$74', - slogan: 'Free dividend tracker for your portfolio' + slogan: 'Free dividend tracker for your portfolio', + url: 'https://plainzer.com' }, { founded: 2023, @@ -788,7 +895,8 @@ export const personalFinanceTools: Product[] = [ key: 'plannix', name: 'Plannix', origin: 'Italy', - slogan: 'Your Personal Finance Hub' + slogan: 'Your Personal Finance Hub', + url: 'https://www.plannix.co' }, { founded: 2015, @@ -798,7 +906,8 @@ export const personalFinanceTools: Product[] = [ name: 'PocketGuard', origin: 'United States', pricingPerYear: '$74.99', - slogan: 'Budgeting App & Finance Planner' + slogan: 'Budgeting App & Finance Planner', + url: 'https://pocketguard.com' }, { founded: 2008, @@ -810,7 +919,8 @@ export const personalFinanceTools: Product[] = [ origin: 'New Zealand', pricingPerYear: '$120', regions: ['Global'], - slogan: 'Know where your money is going' + slogan: 'Know where your money is going', + url: 'https://www.pocketsmith.com' }, { hasFreePlan: false, @@ -820,7 +930,8 @@ export const personalFinanceTools: Product[] = [ name: 'Portfolio Dividend Tracker', origin: 'Netherlands', pricingPerYear: '€60', - slogan: 'Manage all your portfolios' + slogan: 'Manage all your portfolios', + url: 'https://portfoliodividendtracker.com' }, { hasFreePlan: true, @@ -829,7 +940,8 @@ export const personalFinanceTools: Product[] = [ languages: ['English'], name: 'Portfolio Visualizer', pricingPerYear: '$360', - slogan: 'Tools for Better Investors' + slogan: 'Tools for Better Investors', + url: 'https://www.portfoliovisualizer.com' }, { hasFreePlan: true, @@ -849,7 +961,8 @@ export const personalFinanceTools: Product[] = [ name: 'Portseido', origin: 'Thailand', pricingPerYear: '$96', - slogan: 'Portfolio Performance and Dividend Tracker' + slogan: 'Portfolio Performance and Dividend Tracker', + url: 'https://www.portseido.com' }, { founded: 2021, @@ -859,7 +972,8 @@ export const personalFinanceTools: Product[] = [ name: 'ProjectionLab', origin: 'United States', pricingPerYear: '$108', - slogan: 'Build Financial Plans You Love.' + slogan: 'Build Financial Plans You Love.', + url: 'https://projectionlab.com' }, { founded: 2022, @@ -869,7 +983,19 @@ export const personalFinanceTools: Product[] = [ name: 'Pro Stock Tracker', origin: 'United Kingdom', pricingPerYear: '$60', - slogan: 'The stock portfolio tracker built for long-term investors' + slogan: 'The stock portfolio tracker built for long-term investors', + url: 'https://prostocktracker.com' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'rallies', + languages: ['English'], + name: 'Rallies', + pricingPerYear: '$99.99', + slogan: + 'Your entire financial life in one app, monitored continuously by agents', + url: 'https://rallies.ai' }, { founded: 2015, @@ -877,7 +1003,8 @@ export const personalFinanceTools: Product[] = [ key: 'rocket-money', name: 'Rocket Money', origin: 'United States', - slogan: 'Track your net worth' + slogan: 'Track your net worth', + url: 'https://www.rocketmoney.com' }, { founded: 2019, @@ -897,14 +1024,16 @@ export const personalFinanceTools: Product[] = [ name: 'Seeking Alpha', origin: 'United States', pricingPerYear: '$239', - slogan: 'Stock Market Analysis & Tools for Investors' + slogan: 'Stock Market Analysis & Tools for Investors', + url: 'https://seekingalpha.com' }, { founded: 2022, key: 'segmio', name: 'Segmio', origin: 'Romania', - slogan: 'Wealth Management and Net Worth Tracking' + slogan: 'Wealth Management and Net Worth Tracking', + url: 'https://www.segmio.com' }, { founded: 2007, @@ -915,7 +1044,8 @@ export const personalFinanceTools: Product[] = [ origin: 'New Zealand', pricingPerYear: '$135', regions: ['Global'], - slogan: 'Stock Portfolio Tracker' + slogan: 'Stock Portfolio Tracker', + url: 'https://www.sharesight.com' }, { hasFreePlan: true, @@ -932,7 +1062,8 @@ export const personalFinanceTools: Product[] = [ name: 'Simple Portfolio', origin: 'Czech Republic', pricingPerYear: '€80', - slogan: 'Stock Portfolio Tracker' + slogan: 'Stock Portfolio Tracker', + url: 'https://simpleportfolio.app' }, { founded: 2014, @@ -942,7 +1073,8 @@ export const personalFinanceTools: Product[] = [ name: 'Stock Portfolio Tracker & Visualizer by Simply Wall St', origin: 'Australia', pricingPerYear: '$120', - slogan: 'Smart portfolio tracker for informed investors' + slogan: 'Smart portfolio tracker for informed investors', + url: 'https://simplywall.st' }, { founded: 2021, @@ -952,7 +1084,8 @@ export const personalFinanceTools: Product[] = [ name: 'Snowball Analytics', origin: 'France', pricingPerYear: '$80', - slogan: 'Simple and powerful portfolio tracker' + slogan: 'Simple and powerful portfolio tracker', + url: 'https://snowball-analytics.com' }, { key: 'splashmoney', @@ -966,13 +1099,15 @@ export const personalFinanceTools: Product[] = [ key: 'stock-events', name: 'Stock Events', origin: 'Germany', - slogan: 'Track all your Investments' + slogan: 'Track all your Investments', + url: 'https://stockevents.app' }, { key: 'stockle', name: 'Stockle', origin: 'Finland', - slogan: 'Supercharge your investments tracking experience' + slogan: 'Supercharge your investments tracking experience', + url: 'https://stockle.app' }, { founded: 2008, @@ -991,7 +1126,8 @@ export const personalFinanceTools: Product[] = [ name: 'Stock Rover', origin: 'United States', pricingPerYear: '$79.99', - slogan: 'Investment Research and Portfolio Management' + slogan: 'Investment Research and Portfolio Management', + url: 'https://www.stockrover.com' }, { hasFreePlan: true, @@ -1001,7 +1137,8 @@ export const personalFinanceTools: Product[] = [ name: 'Stonksfolio', origin: 'Bulgaria', pricingPerYear: '€49.90', - slogan: 'Visualize all of your portfolios' + slogan: 'Visualize all of your portfolios', + url: 'https://stonksfolio.com' }, { hasFreePlan: true, @@ -1010,7 +1147,8 @@ export const personalFinanceTools: Product[] = [ name: 'Sumio', origin: 'Czech Republic', pricingPerYear: '$20', - slogan: 'Sum up and build your wealth.' + slogan: 'Sum up and build your wealth.', + url: 'https://www.sumio.app' }, { founded: 2016, @@ -1020,7 +1158,8 @@ export const personalFinanceTools: Product[] = [ origin: 'United States', pricingPerYear: '$79', slogan: - 'Your financial life in a spreadsheet, automatically updated each day' + 'Your financial life in a spreadsheet, automatically updated each day', + url: 'https://tiller.com' }, { founded: 2011, @@ -1030,7 +1169,27 @@ export const personalFinanceTools: Product[] = [ name: 'Tradervue', origin: 'United States', pricingPerYear: '$360', - slogan: 'The Trading Journal to Improve Your Trading Performance' + slogan: 'The Trading Journal to Improve Your Trading Performance', + url: 'https://www.tradervue.com' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'trefolio', + languages: [ + 'Deutsch', + 'English', + 'Español', + 'Français', + 'Italiano', + 'Nederlands', + 'Polski', + 'Português' + ], + name: 'trefolio', + pricingPerYear: '€60', + slogan: 'The Extra Leaf for Your Portfolio', + url: 'https://trefolio.com' }, { founded: 2020, @@ -1052,7 +1211,8 @@ export const personalFinanceTools: Product[] = [ name: 'Turbobulls', origin: 'Romania', pricingPerYear: '€39.99', - slogan: 'Your complete financial dashboard. Actually private.' + slogan: 'Your complete financial dashboard. Actually private.', + url: 'https://www.turbobulls.com' }, { hasFreePlan: true, @@ -1063,6 +1223,7 @@ export const personalFinanceTools: Product[] = [ origin: 'Switzerland', pricingPerYear: '$300', slogan: 'Your Portfolio. Revealed.', + url: 'https://www.utluna.com', useAnonymously: true }, { @@ -1072,7 +1233,8 @@ export const personalFinanceTools: Product[] = [ name: 'Vyzer', origin: 'United States', pricingPerYear: '$348', - slogan: 'Virtual Family Office for Smart Wealth Management' + slogan: 'Virtual Family Office for Smart Wealth Management', + url: 'https://vyzer.co' }, { founded: 2020, @@ -1082,7 +1244,8 @@ export const personalFinanceTools: Product[] = [ name: 'Walletguide', origin: 'Germany', pricingPerYear: '€90', - slogan: 'Personal finance reimagined with AI' + slogan: 'Personal finance reimagined with AI', + url: 'https://walletguide.com' }, { hasSelfHostingAbility: false, @@ -1102,7 +1265,8 @@ export const personalFinanceTools: Product[] = [ languages: ['English'], name: 'Wealthbrain', origin: 'United Arab Emirates', - slogan: 'Portfolio Management System' + slogan: 'Portfolio Management System', + url: 'https://wealthbrain.com' }, { founded: 2024, @@ -1113,7 +1277,8 @@ export const personalFinanceTools: Product[] = [ languages: ['English'], name: 'Wealthfolio', origin: 'Canada', - slogan: 'Desktop Investment Tracker' + slogan: 'Desktop Investment Tracker', + url: 'https://wealthfolio.app' }, { founded: 2015, @@ -1124,7 +1289,8 @@ export const personalFinanceTools: Product[] = [ name: 'Wealthica', origin: 'Canada', pricingPerYear: '$50', - slogan: 'See all your investments in one place' + slogan: 'See all your investments in one place', + url: 'https://wealthica.com' }, { founded: 2018, @@ -1132,7 +1298,8 @@ export const personalFinanceTools: Product[] = [ key: 'wealthposition', name: 'WealthPosition', pricingPerYear: '$60', - slogan: 'Personal Finance & Budgeting App' + slogan: 'Personal Finance & Budgeting App', + url: 'https://www.wealthposition.com' }, { founded: 2018, @@ -1141,7 +1308,8 @@ export const personalFinanceTools: Product[] = [ languages: ['English'], name: 'Wealthy Tracker', origin: 'India', - slogan: 'One app to manage all your investments' + slogan: 'One app to manage all your investments', + url: 'https://www.wealthy.in/tracker' }, { key: 'whal', @@ -1170,7 +1338,8 @@ export const personalFinanceTools: Product[] = [ name: 'YNAB (You Need a Budget)', origin: 'United States', pricingPerYear: '$109', - slogan: 'Change Your Relationship With Money' + slogan: 'Change Your Relationship With Money', + url: 'https://www.ynab.com' }, { founded: 2019, @@ -1180,6 +1349,7 @@ export const personalFinanceTools: Product[] = [ name: 'Ziggma', origin: 'United States', pricingPerYear: '$84', - slogan: 'Your solution for investing success' + slogan: 'Your solution for investing success', + url: 'https://ziggma.com' } ]; diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index a0985a979..3c162a310 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -504,11 +504,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { this.holdings = holdings - .filter(({ assetSubClass }) => { - return assetSubClass && !['CASH'].includes(assetSubClass); + .filter(({ assetProfile }) => { + return ( + assetProfile.assetSubClass && + !['CASH'].includes(assetProfile.assetSubClass) + ); }) .sort((a, b) => { - return a.name?.localeCompare(b.name); + return (a.assetProfile.name ?? '').localeCompare( + b.assetProfile.name ?? '' + ); }); this.setPortfolioFilterFormValues(); @@ -530,11 +535,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { type: 'ASSET_CLASS' }, { - id: filterValue?.holding?.dataSource ?? '', + id: filterValue?.holding?.assetProfile?.dataSource ?? '', type: 'DATA_SOURCE' }, { - id: filterValue?.holding?.symbol ?? '', + id: filterValue?.holding?.assetProfile?.symbol ?? '', type: 'SYMBOL' }, { @@ -718,18 +723,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return EMPTY; }), map(({ holdings }) => { - return holdings.map( - ({ assetSubClass, currency, dataSource, name, symbol }) => { - return { - currency, - dataSource, - name, - symbol, - assetSubClassString: translate(assetSubClass ?? ''), - mode: SearchMode.HOLDING as const - }; - } - ); + return holdings.map(({ assetProfile }) => { + return { + assetSubClassString: translate(assetProfile.assetSubClass ?? ''), + currency: assetProfile.currency ?? '', + dataSource: assetProfile.dataSource, + mode: SearchMode.HOLDING as const, + name: assetProfile.name ?? '', + symbol: assetProfile.symbol + }; + }); }), takeUntilDestroyed(this.destroyRef) ); @@ -777,8 +780,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return ( !!(dataSource && symbol) && getAssetProfileIdentifier({ - dataSource: holding.dataSource, - symbol: holding.symbol + dataSource: holding.assetProfile.dataSource, + symbol: holding.assetProfile.symbol }) === getAssetProfileIdentifier({ dataSource, symbol }) ); }); 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/mocks/holdings.ts b/libs/ui/src/lib/mocks/holdings.ts index b32eb527a..11f3bec0e 100644 --- a/libs/ui/src/lib/mocks/holdings.ts +++ b/libs/ui/src/lib/mocks/holdings.ts @@ -4,11 +4,11 @@ export const holdings: PortfolioPosition[] = [ { activitiesCount: 1, allocationInPercentage: 0.042990776363386086, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -20,60 +20,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Apple Inc', sectors: [ { name: 'Technology', weight: 1 } ], - symbol: 'AAPL' + symbol: 'AAPL', + url: 'https://www.apple.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'), dividend: 0, grossPerformance: 3856, grossPerformancePercent: 0.46047289228564603, grossPerformancePercentWithCurrencyEffect: 0.46047289228564603, grossPerformanceWithCurrencyEffect: 3856, - holdings: [], investment: 8374, marketPrice: 244.6, - name: 'Apple Inc', netPerformance: 3855, netPerformancePercent: 0.460353475041796, netPerformancePercentWithCurrencyEffect: 0.036440677966101696, netPerformanceWithCurrencyEffect: 430, quantity: 50, - sectors: [ - { - name: 'Technology', - weight: 1 - } - ], - symbol: 'AAPL', tags: [], - url: 'https://www.apple.com', valueInBaseCurrency: 12230 }, { activitiesCount: 2, allocationInPercentage: 0.02377401948293552, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'DE', @@ -85,60 +65,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'EUR', dataSource: 'YAHOO', holdings: [], + name: 'Allianz SE', sectors: [ { name: 'Financial Services', weight: 1 } ], - symbol: 'ALV.DE' + symbol: 'ALV.DE', + url: 'https://www.allianz.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'DE', - continent: 'Europe', - name: 'Germany', - weight: 1 - } - ], - currency: 'EUR', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'), dividend: 192, grossPerformance: 2226.700251889169, grossPerformancePercent: 0.49083842309827874, grossPerformancePercentWithCurrencyEffect: 0.29306136948826367, grossPerformanceWithCurrencyEffect: 1532.8272791336772, - holdings: [], investment: 4536.523929471033, marketPrice: 322.2, - name: 'Allianz SE', netPerformance: 2222.2921914357685, netPerformancePercent: 0.48986674069961134, netPerformancePercentWithCurrencyEffect: 0.034489367670592026, netPerformanceWithCurrencyEffect: 225.48257403052068, quantity: 20, - sectors: [ - { - name: 'Financial Services', - weight: 1 - } - ], - symbol: 'ALV.DE', tags: [], - url: 'https://www.allianz.com', valueInBaseCurrency: 6763.224181360202 }, { activitiesCount: 1, allocationInPercentage: 0.08038536990007467, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -150,101 +110,73 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Amazon.com, Inc.', sectors: [ { name: 'Consumer Discretionary', weight: 1 } ], - symbol: 'AMZN' + symbol: 'AMZN', + url: 'https://www.aboutamazon.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'), dividend: 0, grossPerformance: 12758.05, grossPerformancePercent: 1.2619300787837724, grossPerformancePercentWithCurrencyEffect: 1.2619300787837724, grossPerformanceWithCurrencyEffect: 12758.05, - holdings: [], investment: 10109.95, marketPrice: 228.68, - name: 'Amazon.com, Inc.', netPerformance: 12677.26, netPerformancePercent: 1.253938941339967, netPerformancePercentWithCurrencyEffect: -0.037866008722316276, netPerformanceWithCurrencyEffect: -899.99926757812, quantity: 100, - sectors: [ - { - name: 'Consumer Discretionary', - weight: 1 - } - ], - symbol: 'AMZN', tags: [], - url: 'https://www.aboutamazon.com', valueInBaseCurrency: 22868 }, { activitiesCount: 1, allocationInPercentage: 0.19216416482928922, - assetClass: 'LIQUIDITY', - assetClassLabel: 'Liquidity', assetProfile: { assetClass: 'LIQUIDITY', - assetSubClass: 'CASH', + assetClassLabel: 'Liquidity', + assetSubClass: 'CRYPTOCURRENCY', + assetSubClassLabel: 'Cryptocurrency', countries: [], currency: 'USD', dataSource: 'COINGECKO', holdings: [], + name: 'Bitcoin', sectors: [], - symbol: 'bitcoin' + symbol: 'bitcoin', + url: undefined }, - assetSubClass: 'CRYPTOCURRENCY', - assetSubClassLabel: 'Cryptocurrency', - countries: [], - currency: 'USD', - dataSource: 'COINGECKO', dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'), dividend: 0, grossPerformance: 52666.7898248, grossPerformancePercent: 26.333394912400003, grossPerformancePercentWithCurrencyEffect: 26.333394912400003, grossPerformanceWithCurrencyEffect: 52666.7898248, - holdings: [], investment: 1999.9999999999998, marketPrice: 97364, - name: 'Bitcoin', netPerformance: 52636.8898248, netPerformancePercent: 26.3184449124, netPerformancePercentWithCurrencyEffect: -0.04760906442310894, netPerformanceWithCurrencyEffect: -2732.737808972287, quantity: 0.5614682, - sectors: [], - symbol: 'bitcoin', tags: [], - url: undefined, valueInBaseCurrency: 54666.7898248 }, { activitiesCount: 1, allocationInPercentage: 0.04307127421937313, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -256,60 +188,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Microsoft Corporation', sectors: [ { name: 'Technology', weight: 1 } ], - symbol: 'MSFT' + symbol: 'MSFT', + url: 'https://www.microsoft.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'), dividend: 0, grossPerformance: 5065.5, grossPerformancePercent: 0.7047750229568411, grossPerformancePercentWithCurrencyEffect: 0.7047750229568411, grossPerformanceWithCurrencyEffect: 5065.5, - holdings: [], investment: 7187.4, marketPrice: 408.43, - name: 'Microsoft Corporation', netPerformance: 5065.5, netPerformancePercent: 0.7047750229568411, netPerformancePercentWithCurrencyEffect: -0.015973588391056275, netPerformanceWithCurrencyEffect: -198.899926757814, quantity: 30, - sectors: [ - { - name: 'Technology', - weight: 1 - } - ], - symbol: 'MSFT', tags: [], - url: 'https://www.microsoft.com', valueInBaseCurrency: 12252.9 }, { activitiesCount: 1, allocationInPercentage: 0.18762679306394897, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -321,60 +233,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Tesla, Inc.', sectors: [ { name: 'Consumer Discretionary', weight: 1 } ], - symbol: 'TSLA' + symbol: 'TSLA', + url: 'https://www.tesla.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'), dividend: 0, grossPerformance: 51227.500000005, grossPerformancePercent: 23.843379101756675, grossPerformancePercentWithCurrencyEffect: 23.843379101756675, grossPerformanceWithCurrencyEffect: 51227.500000005, - holdings: [], investment: 2148.499999995, marketPrice: 355.84, - name: 'Tesla, Inc.', netPerformance: 51197.500000005, netPerformancePercent: 23.829415871596066, netPerformancePercentWithCurrencyEffect: -0.12051410125545206, netPerformanceWithCurrencyEffect: -7314.00091552734, quantity: 150, - sectors: [ - { - name: 'Consumer Discretionary', - weight: 1 - } - ], - symbol: 'TSLA', tags: [], - url: 'https://www.tesla.com', valueInBaseCurrency: 53376 }, { activitiesCount: 5, allocationInPercentage: 0.053051250766657634, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'ETF', + assetSubClassLabel: 'ETF', countries: [ { code: 'US', @@ -386,50 +278,30 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Vanguard Total Stock Market Index Fund ETF Shares', sectors: [ { name: 'Equity', weight: 1 } ], - symbol: 'VTI' + symbol: 'VTI', + url: 'https://www.vanguard.com' }, - assetSubClass: 'ETF', - assetSubClassLabel: 'ETF', - countries: [ - { - code: 'US', - weight: 1, - continent: 'North America', - name: 'United States' - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'), dividend: 0, grossPerformance: 6845.8, grossPerformancePercent: 1.0164758094605268, grossPerformancePercentWithCurrencyEffect: 1.0164758094605268, grossPerformanceWithCurrencyEffect: 6845.8, - holdings: [], investment: 8246.2, marketPrice: 301.84, - name: 'Vanguard Total Stock Market Index Fund ETF Shares', netPerformance: 6746.3, netPerformancePercent: 1.0017018833976383, netPerformancePercentWithCurrencyEffect: 0.01085061564051406, netPerformanceWithCurrencyEffect: 161.99969482422, quantity: 50, - sectors: [ - { - name: 'Equity', - weight: 1 - } - ], - symbol: 'VTI', tags: [], - url: 'https://www.vanguard.com', valueInBaseCurrency: 15092 } ]; 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/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html index f5dbac698..33bde3fd6 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html @@ -29,18 +29,19 @@ [compareWith]="holdingComparisonFunction" > {{ - filterForm.get('holding')?.value?.name + filterForm.get('holding')?.value?.assetProfile?.name }} - @for (holding of holdings(); track holding.name) { + @for (holding of holdings(); track holding.assetProfile.name) {
{{ holding.name }}{{ holding.assetProfile.name }}
{{ holding.symbol | gfSymbol }} · {{ holding.currency }}{{ holding.assetProfile.symbol | gfSymbol }} · + {{ holding.assetProfile.currency }}
diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts index c1f82315c..20e8b0f0f 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts @@ -109,7 +109,8 @@ export class GfPortfolioFilterFormComponent } return ( - getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value) + getAssetProfileIdentifier(option.assetProfile) === + getAssetProfileIdentifier(value.assetProfile) ); } diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 44cef1aed..2ae07708d 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -556,13 +556,11 @@ export class DataService { map((response) => { if (response.holdings) { for (const symbol of Object.keys(response.holdings)) { - response.holdings[symbol].assetClassLabel = translate( - response.holdings[symbol].assetClass - ); + response.holdings[symbol].assetProfile.assetClassLabel = + translate(response.holdings[symbol].assetProfile.assetClass); - response.holdings[symbol].assetSubClassLabel = translate( - response.holdings[symbol].assetSubClass - ); + response.holdings[symbol].assetProfile.assetSubClassLabel = + translate(response.holdings[symbol].assetProfile.assetSubClass); response.holdings[symbol].dateOfFirstActivity = response.holdings[ symbol diff --git a/package-lock.json b/package-lock.json index 02044ceb5..e7b5a2bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.7.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -30,14 +30,14 @@ "@ionic/angular": "8.8.5", "@keyv/redis": "5.1.6", "@nestjs/bull": "11.0.4", - "@nestjs/cache-manager": "3.1.0", - "@nestjs/common": "11.1.19", + "@nestjs/cache-manager": "3.1.2", + "@nestjs/common": "11.1.21", "@nestjs/config": "4.0.4", - "@nestjs/core": "11.1.19", - "@nestjs/event-emitter": "3.0.1", + "@nestjs/core": "11.1.21", + "@nestjs/event-emitter": "3.1.0", "@nestjs/jwt": "11.0.2", "@nestjs/passport": "11.0.5", - "@nestjs/platform-express": "11.1.19", + "@nestjs/platform-express": "11.1.21", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "2.9.0", @@ -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": { @@ -112,17 +113,17 @@ "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", - "@nestjs/testing": "11.1.19", - "@nx/angular": "22.7.2", - "@nx/eslint-plugin": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/nest": "22.7.2", - "@nx/node": "22.7.2", - "@nx/storybook": "22.7.2", - "@nx/web": "22.7.2", - "@nx/workspace": "22.7.2", + "@nestjs/testing": "11.1.21", + "@nx/angular": "22.7.5", + "@nx/eslint-plugin": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/nest": "22.7.5", + "@nx/node": "22.7.5", + "@nx/storybook": "22.7.5", + "@nx/web": "22.7.5", + "@nx/workspace": "22.7.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -149,7 +150,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.7.2", + "nx": "22.7.5", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", @@ -7381,13 +7382,13 @@ } }, "node_modules/@module-federation/bridge-react-webpack-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.4.0.tgz", - "integrity": "sha512-yxDv/FJoLiKo2eqIcEWvSnSpJgyYkCzJvNaFsQ2QE3rNv68IeAarlSzCo+d0QyQoPJnTETyHsOh1SSBazIzecw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.5.0.tgz", + "integrity": "sha512-Ux9XVW//K6K+KHKPdc0Jnc7RtTpZaEXgbVhp5yovtFkCJVt8hEClcTeuI18MvvLiV/q2hUpCU5Wsf9zNaIYStQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.4.0", + "@module-federation/sdk": "2.5.0", "@types/semver": "7.5.8", "semver": "7.6.3" } @@ -7406,14 +7407,14 @@ } }, "node_modules/@module-federation/cli": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.4.0.tgz", - "integrity": "sha512-c46g9srroc2hDfrlHyd4Y404SLnw3v9t7Kqij+yK01Hx8C2FyZpyanTGUHVyrmzqp/0y3lPrWURUHkHfk/cJQA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.5.0.tgz", + "integrity": "sha512-+czXA6yoiiF9W6+YEOCpQE6zpGZpA89X0oCEz3EaWPTkL4chEbxurjpME8CMnJk9iuFxl167+cBQiQlVBiHGGg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/sdk": "2.4.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/sdk": "2.5.0", "commander": "11.1.0", "jiti": "2.4.2" }, @@ -7425,16 +7426,16 @@ } }, "node_modules/@module-federation/dts-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.4.0.tgz", - "integrity": "sha512-sa6v5ByyqMRHzpwDu0zc7s5mZ39EFIkG0jkRfZU09pzkrJEIy4uZ1Kt9SLysFB8RBMIAvAakAfqDlVWvf1lndg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.5.0.tgz", + "integrity": "sha512-q7KDhJ5tn2HrUV7uMuh/L3TaaztUosE+4LAb90sxx0pPPqWRwlpBpxu1REubv5BWXmU1K/Ozn14u6jRbjLVaGA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/sdk": "2.4.0", - "@module-federation/third-party-dts-extractor": "2.4.0", + "@module-federation/error-codes": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/sdk": "2.5.0", + "@module-federation/third-party-dts-extractor": "2.5.0", "adm-zip": "0.5.10", "ansi-colors": "4.1.3", "isomorphic-ws": "5.0.0", @@ -7463,23 +7464,23 @@ } }, "node_modules/@module-federation/enhanced": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.4.0.tgz", - "integrity": "sha512-NiccK03x7V6bK2LvJNuW520kT+Onx+LJe8lyPsENjXctECCIFJdJOmYr8ABif/kLayWKrrYCzCGVNNiQXANEGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.4.0", - "@module-federation/cli": "2.4.0", - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/error-codes": "2.4.0", - "@module-federation/inject-external-runtime-core-plugin": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/manifest": "2.4.0", - "@module-federation/rspack": "2.4.0", - "@module-federation/runtime-tools": "2.4.0", - "@module-federation/sdk": "2.4.0", - "@module-federation/webpack-bundler-runtime": "2.4.0", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.5.0.tgz", + "integrity": "sha512-P91tzwyKSCQ6AwirqvAvTqWqmTY79ndpH0uenejFw+bbLpWrjuY0q+iZUXCV/7CSNmqwH2bkA/ssuyZljmcMVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/bridge-react-webpack-plugin": "2.5.0", + "@module-federation/cli": "2.5.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/error-codes": "2.5.0", + "@module-federation/inject-external-runtime-core-plugin": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/manifest": "2.5.0", + "@module-federation/rspack": "2.5.0", + "@module-federation/runtime-tools": "2.5.0", + "@module-federation/sdk": "2.5.0", + "@module-federation/webpack-bundler-runtime": "2.5.0", "schema-utils": "4.3.0", "tapable": "2.3.0", "upath": "2.0.1" @@ -7543,56 +7544,56 @@ } }, "node_modules/@module-federation/error-codes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.4.0.tgz", - "integrity": "sha512-ktCZtwOoiKR1URJyBt223OsOFAUvc13rICYif55mt7+DomtELlh5FicnEz6mPLBUwmNM9vyBMvkxOdp+fQ5oUg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.5.0.tgz", + "integrity": "sha512-sq05/8Gp3csy1nr2/f76K3vLy0/xRqVtP71ibGy8BiLg7h1UxWN7G4EwAKSrPZ4FnsERGeFlIszg5Z+MqlwhFg==", "dev": true, "license": "MIT" }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.4.0.tgz", - "integrity": "sha512-GucUMQmQXcnJC/OnJGvMz3Qy7ap8nAffhQPwDpOSi0Qwm+Iq/ppzG8N3tlLBDmv/O8hiF8HHlg789XK2kcCQtg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.5.0.tgz", + "integrity": "sha512-e2KyTHpesBrPXGHMh4d4+s2xBiNoxbiFJkPRYHMCl81a/Gu+byrMkriZcV4VM/TFvBIlrgOJisVc1nnBI5UDRQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@module-federation/runtime-tools": "2.4.0" + "@module-federation/runtime-tools": "2.5.0" } }, "node_modules/@module-federation/managers": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.4.0.tgz", - "integrity": "sha512-Z8j6aog44G1gt4yIAaeDowwZ7xg0aAxTA1Hq69euJK9cR9MDEaLbLUk57jDoiRj6xLwlCiw7ozY+U15BQATk6Q==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.5.0.tgz", + "integrity": "sha512-9b5mU/7OYbKrYUJmhZ1kkfeJCZqR7qX6/FWp+oOfZMzUynN7Rb41dwoUs3TdnOKzbZ3CCwtZ2WsR4pF9ZNvuJA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.4.0", + "@module-federation/sdk": "2.5.0", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/manifest": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.4.0.tgz", - "integrity": "sha512-ZL+W5rbtgRf9TWRP7Dupt/Svia4bJEOS6gWSj9jzemiLPRPkMO5hjWZKVHIc8oG+Vb25yzozFMmQ+luGi695wg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.5.0.tgz", + "integrity": "sha512-pmwQCGWjM2oKY7CkR7nEDOfMK0bNFJUifuDxuOB5iOWhU+Rp92UyyBI9IbJAtiISTSFGtuKRy40peJGvQq2VcQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/sdk": "2.4.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/sdk": "2.5.0", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/node": { - "version": "2.7.42", - "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.42.tgz", - "integrity": "sha512-aX/T4L9bPbOgNLIW+30k/dA2Iohoy9/jf4yG1ka6Hkuo5h7iEBeZiQkwIqC06cnCbtKL1HnAiYlXHmrDPW5xvg==", + "version": "2.7.43", + "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.43.tgz", + "integrity": "sha512-oKoLm7dqb5EvkiNIfsEdLmmBX7XLWHtPSx3M9kEYuXAaNAppoRWC9WtgrrZYXWErB2BG9wxMlx/8Xq3awRUCdQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "2.4.0", - "@module-federation/runtime": "2.4.0", - "@module-federation/sdk": "2.4.0", + "@module-federation/enhanced": "2.5.0", + "@module-federation/runtime": "2.5.0", + "@module-federation/sdk": "2.5.0", "encoding": "0.1.13", "node-fetch": "2.7.0", "tapable": "2.3.0" @@ -7607,19 +7608,19 @@ } }, "node_modules/@module-federation/rspack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.4.0.tgz", - "integrity": "sha512-NWH5Vaj/fA9R7PfbwTuE1Ty/pfiAt12On0E3FzoeVPCyb5MxO1i0z+xxRHbPhF4ZOrAPGEMaMQ8Z9vH94EiElw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.5.0.tgz", + "integrity": "sha512-OAFMpMXuLEQFmWBuC1I7LNDQ8N3CDANXe0YGPWkIPNxKq5Tj/KNfDidmutoYgvXlZKOM4yKBKBsL6Xt/UvtOIw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.4.0", - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/inject-external-runtime-core-plugin": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/manifest": "2.4.0", - "@module-federation/runtime-tools": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/bridge-react-webpack-plugin": "2.5.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/inject-external-runtime-core-plugin": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/manifest": "2.5.0", + "@module-federation/runtime-tools": "2.5.0", + "@module-federation/sdk": "2.5.0" }, "peerDependencies": { "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0", @@ -7636,43 +7637,43 @@ } }, "node_modules/@module-federation/runtime": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.4.0.tgz", - "integrity": "sha512-IrLAMwUuteRgFlEkg9jrn4bk8uC897FnXvfNmkKD8/qIoNtSd+32e5ouQn+PEYbX/RjRUB1TYveY6rYHpTPkyg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.5.0.tgz", + "integrity": "sha512-dOc7pFEf8aruHBk5hoJLnvwkCa5ELT78q3o9dqcdaa/TT74X5z0FT0BsaGaRBPcse/iP6czK3fWd7RLv5ZKP5g==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/runtime-core": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/error-codes": "2.5.0", + "@module-federation/runtime-core": "2.5.0", + "@module-federation/sdk": "2.5.0" } }, "node_modules/@module-federation/runtime-core": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.4.0.tgz", - "integrity": "sha512-0S8fDw28DXDW17lTQwq5vfJWe2lG0Lw3+w4vk3DVVImLwXXay+OGxLDxzWUfypWcMznfpnoAnFUMO3PtuXziuA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.5.0.tgz", + "integrity": "sha512-STmhQ3c6/hunba2FMP6GrHazXU/8GuN7Gk4dOkWNRpnqYIoD8Wx4MNl76j3HdCzBESC7uSMXTniksVaM1+xxyA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/error-codes": "2.5.0", + "@module-federation/sdk": "2.5.0" } }, "node_modules/@module-federation/runtime-tools": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.4.0.tgz", - "integrity": "sha512-BWQsGT4EWscV9bx3bVHEwp6lERBsiYm7rnPiDpwd2fx+hGEpz1IM9Pz35VryHNDXYxw7MzaAuwTMM+L7uN8OYQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.5.0.tgz", + "integrity": "sha512-fR3Na6V78ov3/O17Mev+1vydfmqlYWP4ZNxD/bBkmqKhCO7jMdthNTT02yDljlCyhYl6+X90UJlFhwFle6rIsw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/runtime": "2.4.0", - "@module-federation/webpack-bundler-runtime": "2.4.0" + "@module-federation/runtime": "2.5.0", + "@module-federation/webpack-bundler-runtime": "2.5.0" } }, "node_modules/@module-federation/sdk": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.4.0.tgz", - "integrity": "sha512-eZDdF5B69W9npuka0VL24FY7XDM+YAwwfkscSeWOSqv4/8Hm0xmcmSurlP6NIOrwbeogerRCtEcnx/TFXYjoow==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.5.0.tgz", + "integrity": "sha512-ScU22XDyV77l50njjzewMpMlNN1CYo0tHS1D6iy+vNKWrHGq8DWVB0vwG8dmvx/WZ4uq+sXgUsQet17MoKsfZw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7685,9 +7686,9 @@ } }, "node_modules/@module-federation/third-party-dts-extractor": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.4.0.tgz", - "integrity": "sha512-4v24t6L3dET/6abMOM2fiM3roT0c8mi21/i+uDc6WG7U0i+Xp2SojBppTs6gnT0lkwMTe+u6xIpNQakdUftHsg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.5.0.tgz", + "integrity": "sha512-5di43LGk2ies86Cj8QyzYr540Ijc+nyPqYziyFotL6Pparnu+uf3b3ERfEyQfBmEcyGk1MpitQIO2J3bd9BcNw==", "dev": true, "license": "MIT", "dependencies": { @@ -7696,15 +7697,15 @@ } }, "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.4.0.tgz", - "integrity": "sha512-Ntx0+QsgcwtXlpGjL/Vf2PMdPjUHl07b3yM4kBc1kbRogW3Ee84QneBRi/X3w4/jlz4JKbHjD+CMXaqi2W6hgw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.5.0.tgz", + "integrity": "sha512-UxVad+tNZYkBnZzqJQsZa0pB5gO5cJoCjMumOo3bhzXBJVqHsFupfeHa8Nk7WrRVbJE6zRT9ZHK0s0NDWBMyJw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/runtime": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/error-codes": "2.5.0", + "@module-federation/runtime": "2.5.0", + "@module-federation/sdk": "2.5.0" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { @@ -8141,9 +8142,9 @@ } }, "node_modules/@nestjs/cache-manager": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.0.tgz", - "integrity": "sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.2.tgz", + "integrity": "sha512-Eglt8lUzC3Q3OZ2hFt4vLZ190M94YSJXUiKo67K/zlUgZQGtvxL0AYeKbG96x8+1gJTF7QhFpYw/RkQ28416Mw==", "license": "MIT", "peerDependencies": { "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", @@ -8154,9 +8155,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.19", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.19.tgz", - "integrity": "sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q==", + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.21.tgz", + "integrity": "sha512-YV1HYDGsm2rnR0vrLKidtrG6jYX5yqiIjeur1j8++dKGqhhsJ6cjMs0RfQRSTUH7IjgDemA59/znQ8nRrE0D9g==", "license": "MIT", "dependencies": { "file-type": "21.3.4", @@ -8212,9 +8213,9 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.19", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.19.tgz", - "integrity": "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw==", + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.21.tgz", + "integrity": "sha512-fqo0BHgny3MOuAL8GSfG3ZUKFVVBaBQD/0iyibnwTONT5vPexjQxJzu+945iloVvBDmrnAaRWxC1gqCDEs/AXQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -8253,9 +8254,9 @@ } }, "node_modules/@nestjs/event-emitter": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", - "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.1.0.tgz", + "integrity": "sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==", "license": "MIT", "dependencies": { "eventemitter2": "6.4.9" @@ -8289,9 +8290,9 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.19", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.19.tgz", - "integrity": "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==", + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.21.tgz", + "integrity": "sha512-lA3ViycOnz4Df3EstIKpuAVFhqxQixTnjAVk0M+LRyNBlGM6VSCaNJaAIrb9Pcry39T4hTHpNVbRqGLSvhL8gA==", "license": "MIT", "dependencies": { "cors": "2.8.6", @@ -8572,9 +8573,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.1.19", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.19.tgz", - "integrity": "sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==", + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.21.tgz", + "integrity": "sha512-RhzaUFxr6/bpXWjKIzr7p2eHKMFMLwPgsxJNFcCf2CkkT3UEjW+KRGb7E2JY+fh+ck3zAdvQJrzATDnSsVlFZw==", "dev": true, "license": "MIT", "dependencies": { @@ -8963,20 +8964,20 @@ } }, "node_modules/@nx/angular": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.7.2.tgz", - "integrity": "sha512-+HCggLwJXp55ZdKrn0VkYfw9gGgZpiIHdlY8m3KnwJzdA+Tfl9t10JvidFXprk7gmnRaU8hHidfz6e1juG6D6g==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.7.5.tgz", + "integrity": "sha512-M+xTktTN0VBGpvFsK5u+8oMPZhD3Du2nr/b2U/EpqnfWFb2y7r7nIhQT8NYjvVlGCRyKjJi6tXNHxND6KLqr0g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/rspack": "22.7.2", - "@nx/web": "22.7.2", - "@nx/webpack": "22.7.2", - "@nx/workspace": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/rspack": "22.7.5", + "@nx/web": "22.7.5", + "@nx/webpack": "22.7.5", + "@nx/workspace": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "enquirer": "~2.3.6", @@ -9024,15 +9025,15 @@ } }, "node_modules/@nx/cypress": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.7.2.tgz", - "integrity": "sha512-ivrwIXNTn0p9nGg2z3mjZJYPuH7X+O3eMtuqyPglkxKlAhuUQXVmKYB9nIqRMGR2nGCi9cDC7J38h+0Py+TunA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.7.5.tgz", + "integrity": "sha512-R7vlStn1ukKL9WL/dtfESKeqC38LyDvapPdSbxlBiDISCMgJAzntLcoBM22LfR3z7xy4kDk21fDMwglO3Bj30A==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "detect-port": "^2.1.0", "semver": "^7.6.3", @@ -9049,9 +9050,9 @@ } }, "node_modules/@nx/devkit": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.2.tgz", - "integrity": "sha512-oE2SFUxQeZm/EmFABHpWQ4Pi0fBKbJbXKGPvdFaHoMumRxhqBhuBVf/ap5kYFg8Y9bK/zHJkpsEbGyiyRrhvog==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.5.tgz", + "integrity": "sha512-/63ziS7kdHXYTLLhwWBu9hFwoFFT8xf+PkcQjsNdPqc5JmkYkSew0cE/vp5ORgBpGLWWnFPJgmfqjbJoO2C7jA==", "dev": true, "license": "MIT", "dependencies": { @@ -9120,32 +9121,32 @@ } }, "node_modules/@nx/docker": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.7.2.tgz", - "integrity": "sha512-VSORTGE28czjDePM5XvNnbwneowlT/6N0t0Jhh6cJtSGCCWwaT4WQb8uVYOgchDr77HwOGhgezI79mmoKHWnsw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.7.5.tgz", + "integrity": "sha512-IuizX/ACvAjoTIued7eHFDaknSL6WVfDTMtzxiqaY+iDpdOwCVTC1ZXQZSMba/xEsh4owk4qnSRmJ2eWSWburw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", + "@nx/devkit": "22.7.5", "enquirer": "~2.3.6", "tslib": "^2.3.0" } }, "node_modules/@nx/eslint": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.7.2.tgz", - "integrity": "sha512-LDWFg6CNtORnEnwB3XSJBjm8QnheN3F9HxE/kq69Fx+4drkSYEXjRx+27M+9kSP1z2HriSQn5LjEmz1yQD8stA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.7.5.tgz", + "integrity": "sha512-D/85AvnF07ng/fLcSvAE8bxFQKvejUc/MP4pX6aFZgRGrpduo7mTwFMlM/UtOtTTPRRRQVLmM9u7jn4JKROBRw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "semver": "^7.6.3", "tslib": "^2.3.0", "typescript": "~5.9.2" }, "peerDependencies": { - "@nx/jest": "22.7.2", + "@nx/jest": "22.7.5", "@zkochan/js-yaml": "0.0.7", "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" }, @@ -9159,14 +9160,14 @@ } }, "node_modules/@nx/eslint-plugin": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.7.2.tgz", - "integrity": "sha512-OgfyUt4dUrlTHcnygVLXcxP0KH7yOAaB9pfdWLe86QRWm2Ei4sRdACltPRXoa9tDeEa4EdBvDTccw0AZ3UnrvQ==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.7.5.tgz", + "integrity": "sha512-C9mLUAZjcAKvkAifLNxNBWzvX9RFc/fg+GbO0d50596Lw3Yoz5tRCm4mgpUbVI3mkMIQumjoe8hu9bFx85bXnw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0", @@ -9201,16 +9202,16 @@ } }, "node_modules/@nx/jest": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.7.2.tgz", - "integrity": "sha512-t+UYRCUUT7BYoRohjf6lWVzeeITjytclxE1ENEzU0+PCAKYN8yJfAWLSsLfK4YDDBv2lTXyzHo7b5Pxpbmz+Qw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.7.5.tgz", + "integrity": "sha512-+WlVdtDlVM1dyJKQg/gKiMMs6B4cR8Qh3NT9J2WFPbKdD89DTR771j+WZE572MLijJmqzOE7uNH189kV1qEj0A==", "dev": true, "license": "MIT", "dependencies": { "@jest/reporters": "^30.0.2", "@jest/test-result": "^30.0.2", - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "identity-obj-proxy": "3.0.0", "jest-config": "^30.0.2", @@ -9264,9 +9265,9 @@ } }, "node_modules/@nx/js": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.7.2.tgz", - "integrity": "sha512-d1Hb/2n3QKE9rs8gRtfa/b1/GCGm1rnBFiqePivWbD/9iqerhgkbs6cg4MliLGRnD8gZgXSENLm4IW8ISOi69w==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.7.5.tgz", + "integrity": "sha512-2nJdlNPwYRldsdmUz+p/O8kF7eVjINaycTO4o1FXn8DL09wLvhxb1kFAaJrGA3Ig6znAnmRVGitccFt1QTPCIg==", "dev": true, "license": "MIT", "dependencies": { @@ -9277,8 +9278,8 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", - "@nx/devkit": "22.7.2", - "@nx/workspace": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/workspace": "22.7.5", "@zkochan/js-yaml": "0.0.7", "babel-plugin-const-enum": "^1.0.1", "babel-plugin-macros": "^3.1.0", @@ -9345,18 +9346,18 @@ } }, "node_modules/@nx/module-federation": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.7.2.tgz", - "integrity": "sha512-8KblqEdVw0b6uzhVSxz+RbjodN1BnHtWk1J4ndxG5XxiDLvW8bVEmpQAfn6DebsSRTIr+N/e3pah84j7xhZ9Yw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.7.5.tgz", + "integrity": "sha512-tb2j891NYZvl2jMFWbTgF2aLGOGDjhJrMbUKIml2/xIT6DNF89+ly8+wjMS5Nh7JAK3XSQtlERlFVBCcTD+bQA==", "dev": true, "license": "MIT", "dependencies": { "@module-federation/enhanced": "^2.3.3", "@module-federation/node": "^2.7.21", "@module-federation/sdk": "^2.1.0", - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", - "@nx/web": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", + "@nx/web": "22.7.5", "@rspack/core": "1.6.8", "express": "^4.21.2", "http-proxy-middleware": "^3.0.5", @@ -9777,41 +9778,41 @@ } }, "node_modules/@nx/nest": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.7.2.tgz", - "integrity": "sha512-Xpja5pry0RWJ8K5t25Eh0HCe5Z6kg81oZh3Qr17iodh4jxAKCMbSpx+1j8PNmC6PFfzbhJyXY6gWOtnaCgbh3g==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.7.5.tgz", + "integrity": "sha512-UhCLlH3UjankgIFcQi6ZYEgAKSfKQlk6g9jJJpN+yyPDvoQYDPOr0iPicO+dUJvX+xDOxI/jS8easNuZ64fVPA==", "dev": true, "license": "MIT", "dependencies": { "@nestjs/schematics": "^11.0.0", - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", - "@nx/node": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", + "@nx/node": "22.7.5", "tslib": "^2.3.0" } }, "node_modules/@nx/node": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.7.2.tgz", - "integrity": "sha512-6nj6siMZy45r4hITYfHcqrOFpadYEkbfqwQP3xgTXZvTt6foX0HHoeOcv1rvaEvgdG6/DuZWQj4z8ursCnMWPw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.7.5.tgz", + "integrity": "sha512-BJckjbyOgClqD6h2mNDhjftSRsvxbuayw0vKpT9sbd1ivAVhUCqNpe/iVP/VEdTsaK3s26hacfifD8EH3fPNWQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/docker": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/docker": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/js": "22.7.5", "kill-port": "^1.6.1", "tcp-port-used": "^1.0.2", "tslib": "^2.3.0" } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.2.tgz", - "integrity": "sha512-hu+x/IOzx+18imkFwSdtXnvB6d21qcXvc4bCqcbA9BQcUnvTnw0/11SLoasvDqy/9KLKHDWJAIPttcBkbArWVA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.5.tgz", + "integrity": "sha512-eoPtwx0qZqvRUD+VVOHm150AlSYwYoPxkDHBBGqKCn5nzPspb0lLWw8q83crM/L1M928YgK0WmGf3C++7eqsTA==", "cpu": [ "arm64" ], @@ -9823,9 +9824,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.2.tgz", - "integrity": "sha512-M4QPs4rjzZN51V7qiKUjJU7hLYtv/h0I/aGUedCQQZibbbDTl45sQlgBQlV/viw2dOw3K5+RxDxtMNFxAbhxQA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.5.tgz", + "integrity": "sha512-VLOn/ZoEn3HfjSj+yIHLCM56/el79r+9I28CkZNHaSXJQWZ3edSkcgcfYjVxCurpN2VEwDQHLBeFCH8M+lQ7wQ==", "cpu": [ "x64" ], @@ -9837,9 +9838,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.2.tgz", - "integrity": "sha512-tdC2mBQ/ON9qvTs72aL3XVN7B5wd7UsiRJ/qwC2bk/PIpD0vo5c3EwxFyYXfTD60jnlV+CTFxhSVmu8S1pVsfw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.5.tgz", + "integrity": "sha512-LEVer/E2xfGvK9Go+imMQoEninOoq/38Z2bhV1SD3AThXrp1xaLFVkW5jQ6juebeVkAeztEoMLFlr576egS0vw==", "cpu": [ "x64" ], @@ -9851,9 +9852,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.2.tgz", - "integrity": "sha512-bBHIC9xZ8L12BWkwMKbRi7+oV4UH1v1Yy8PsIvRfjS7GzYNlOAUMkJxywjF2msnkp8M8Rn29MEvzllZjdyaR7Q==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.5.tgz", + "integrity": "sha512-NP27EFGpmFJM6RL1Ey/AFJ7gA2xuqtIHaw6jjSNGvfrnZRUNaway30GrVaGGeODf0DsvAty/unqoBMPy6kDHbw==", "cpu": [ "arm" ], @@ -9865,9 +9866,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.2.tgz", - "integrity": "sha512-MBYG58VUTmLW4S2RlYmXJiV6P0P1lkiZXtiaulZOXmP5uCSXiqMgK47k56hq9GTbtW1SpyGgh02lkNdCYTbmLw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.5.tgz", + "integrity": "sha512-QLnkJl3HkHsPfpLiNiAiMfpfAeFpic0U1diAxF8RqChOkCpQ7ulvyBVgE1UrQxvhd+gFQ3ed5RNDxtCRw8nTiw==", "cpu": [ "arm64" ], @@ -9879,9 +9880,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.2.tgz", - "integrity": "sha512-Wf4VBSJt5gEGdzX6uzZoITEYB/Y3TxjvPNT11NKfRU/m63b8/D8jCeRmr7cBTaMUlNmdH3Lf3G1PuPNGoEZ0Mg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.5.tgz", + "integrity": "sha512-cEP6KmwBgnb38+jTTaibWCjwXcHmigqhTfy0tN1be7WZr6bHxbqNLsXqKRN70PSNA3HouZcxw1cdRL8tqbPBBA==", "cpu": [ "arm64" ], @@ -9893,9 +9894,9 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.2.tgz", - "integrity": "sha512-v3AQyfCkv9k+AWT2hy8hAGaCmFYf+G/bt4KAqnWhmXPWNhxrv9FhvTUcjpY+MY+6v7sKdhJv/3eDvtlLd9FOLg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.5.tgz", + "integrity": "sha512-tbaX1tZCSpGifDNBfDdEZAMxVF3Yg4bhFP/bm1needc0diqb+Zflc0u5tM5/6BWDMITQDwenJVsNiQ8ZdtJURA==", "cpu": [ "x64" ], @@ -9907,9 +9908,9 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.2.tgz", - "integrity": "sha512-3SMfMB7ynr8wGGTZP+/ZV7FqkCsOg1Raoka+4EtIPX66bEcBycg8FVg81DbyV+IzuKk3N+8Hl2IeY1W2btPypw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.5.tgz", + "integrity": "sha512-H0M7csOZIgPT822LqjxSXzf4MXRND15vIkAQe3F3Jlr3Si8LC3tzbL52aVcRfgb8MF/xOB5U47mSwxWt1M2bPQ==", "cpu": [ "x64" ], @@ -9921,9 +9922,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.2.tgz", - "integrity": "sha512-eTFTTF1JUKXu+PNOGd7KAdqyWyfvFKO/wpqHoq9fjnbjXgCdCg1PaRxHIxA1WT5HFj1iHS6Or+GC1zA1KNt0Sw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.5.tgz", + "integrity": "sha512-JTcZch9YAnDL1gbhqePz3DZ4x7iYemLn1yJzrjbbXAmXju2eiiJiZvJJHbV06+SP9HKXDT8RjTKuAWTdVxnHug==", "cpu": [ "arm64" ], @@ -9935,9 +9936,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.2.tgz", - "integrity": "sha512-fbVAiJ7RKSanUXrL67Z6as7BY1akznRqo71ACmrxLvLicG3UsmATbHKGp0zULoe3jBm+rNrIrLk+quZn5q0wUg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.5.tgz", + "integrity": "sha512-ngcMyHdBJ9FSz2nHdbZ7gtJlFq0O2b05sPAsVMkZ18CKzdaA1qrBDJfsMO49hPCny505eiT766+CkKdaCDl5kA==", "cpu": [ "x64" ], @@ -9949,16 +9950,16 @@ ] }, "node_modules/@nx/rspack": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.7.2.tgz", - "integrity": "sha512-hlBIqhL9otJEQ9x8pf6CYqz0DdTKRW1w3jQ7c1hSafEHrC+OHs3UGReYeDO9Avua5eNA4/FVMLUVFUOPNzk3Wg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.7.5.tgz", + "integrity": "sha512-E0esSN1S3e0eiHPlNbMsy6guu7APga9C1J5eO9b9IL4dB5NWXUXPsRhfPSv/8hp1gIho/rmqobItaDFgyt6KBQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/web": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/web": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "@rspack/core": "1.6.8", "@rspack/dev-server": "^1.1.4", @@ -10458,22 +10459,22 @@ } }, "node_modules/@nx/storybook": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.7.2.tgz", - "integrity": "sha512-kf34LHK0nvyTsXG43FqBGBBPzq9Th90hdWUVktLo636K6/mDvVPTM033BPzQDIljEqWAD+34YPiCtKGLE3H1Ag==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.7.5.tgz", + "integrity": "sha512-ZA86Gdhbq93oQjbav+RyrzUzA0nEydQKLu1oY+L6LYXE+IK3Rrjr584VJW5KySAHmu4dgCNcvnS+lyZKXK5SXQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/cypress": "22.7.2", - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", + "@nx/cypress": "22.7.5", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "semver": "^7.6.3", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/web": "22.7.2", + "@nx/web": "22.7.5", "storybook": ">=7.0.0 <11.0.0" }, "peerDependenciesMeta": { @@ -10483,26 +10484,26 @@ } }, "node_modules/@nx/web": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.7.2.tgz", - "integrity": "sha512-DgjlnOlPOpRFHJuItUbm3+DRZqZQkqVUTRhxS/Ep5QtMx/KeO6jbULHFS4BTDV54/I20ejjsWvADYcYhsZaY1g==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.7.5.tgz", + "integrity": "sha512-mJOx3BknJhdr2T7UD4b4LuWyTa+MyXXJvYymBjHvBuCmWC5o68wuDm2y5kXrZ1WxHJYlqoLZ2281QPVyB+SZ7A==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "detect-port": "^2.1.0", "http-server": "^14.1.0", "picocolors": "^1.1.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/cypress": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/playwright": "22.7.2", - "@nx/vite": "22.7.2", - "@nx/webpack": "22.7.2" + "@nx/cypress": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/playwright": "22.7.5", + "@nx/vite": "22.7.5", + "@nx/webpack": "22.7.5" }, "peerDependenciesMeta": { "@nx/cypress": { @@ -10526,15 +10527,15 @@ } }, "node_modules/@nx/webpack": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.7.2.tgz", - "integrity": "sha512-XVJcf2Bn1P7GoxJRESKFF5bXqWGPyE6+Bw1BraUmtM7bBRiF3tSXaYrzdwQlyNxZdK82RH+Y8hkBSXX3VZ8Rkg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.7.5.tgz", + "integrity": "sha512-R6LUNAiwQANzqnRDVG2Z4nBMOUQX8Pud1BzllK+CgJkT8qK5eRjVjkBMCEm42togZ8Ax5/BYzO5w4gqUVKfvOQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.23.2", - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "ajv": "^8.12.0", "autoprefixer": "^10.4.9", @@ -10746,17 +10747,17 @@ } }, "node_modules/@nx/workspace": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.7.2.tgz", - "integrity": "sha512-xTEQMkeltIS6V5Qb6QRA7O+HIJQjIZSxLm6SvBNczJqAxckuYwMdbrb2IkDSE0XnQqR3gYg7Isz6UuBUHjz66Q==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.7.5.tgz", + "integrity": "sha512-f3zx8EAOl0ANd2UXZIniBoHfDvNvi2Uy65R9Rp6emdcx7rxsuTU5Eaidryleo9wIQ5cZAcMx7Wvzp5Srj8diKA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", + "@nx/devkit": "22.7.5", "@zkochan/js-yaml": "0.0.7", "chalk": "^4.1.0", "enquirer": "~2.3.6", - "nx": "22.7.2", + "nx": "22.7.5", "picomatch": "4.0.4", "semver": "^7.6.3", "tslib": "^2.3.0", @@ -19077,9 +19078,9 @@ } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -19097,7 +19098,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -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, @@ -30315,9 +30316,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -30862,9 +30863,9 @@ "license": "MIT" }, "node_modules/nx": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.2.tgz", - "integrity": "sha512-Gh7gGO1t/TvgbKuVJMYWbxUwZC+E+PuRRVUeoOeVe82yEvBNl40EKiVHIbbi6GID0s9Zwzflo07UrKGLoDSVGw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.5.tgz", + "integrity": "sha512-zoxsJabb33jl1QYnalDn0bicryrEBgSzdKp90d7VGGv/jDgzKrcLg/hw2ZxeYiOjWPIT/o8QNT9G9vTs4dv3AQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -30886,7 +30887,7 @@ "balanced-match": "4.0.3", "base64-js": "1.5.1", "bl": "4.1.0", - "brace-expansion": "5.0.5", + "brace-expansion": "5.0.6", "buffer": "5.7.1", "call-bind-apply-helpers": "1.0.2", "chalk": "4.1.2", @@ -30967,7 +30968,7 @@ "strip-bom": "3.0.0", "supports-color": "7.2.0", "tar-stream": "2.2.0", - "tmp": "0.2.4", + "tmp": "0.2.6", "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tslib": "2.8.1", @@ -30976,7 +30977,7 @@ "wrap-ansi": "7.0.0", "wrappy": "1.0.2", "y18n": "5.0.8", - "yaml": "2.8.0", + "yaml": "2.9.0", "yargs": "17.7.2", "yargs-parser": "21.1.1" }, @@ -30985,16 +30986,16 @@ "nx-cloud": "dist/bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "22.7.2", - "@nx/nx-darwin-x64": "22.7.2", - "@nx/nx-freebsd-x64": "22.7.2", - "@nx/nx-linux-arm-gnueabihf": "22.7.2", - "@nx/nx-linux-arm64-gnu": "22.7.2", - "@nx/nx-linux-arm64-musl": "22.7.2", - "@nx/nx-linux-x64-gnu": "22.7.2", - "@nx/nx-linux-x64-musl": "22.7.2", - "@nx/nx-win32-arm64-msvc": "22.7.2", - "@nx/nx-win32-x64-msvc": "22.7.2" + "@nx/nx-darwin-arm64": "22.7.5", + "@nx/nx-darwin-x64": "22.7.5", + "@nx/nx-freebsd-x64": "22.7.5", + "@nx/nx-linux-arm-gnueabihf": "22.7.5", + "@nx/nx-linux-arm64-gnu": "22.7.5", + "@nx/nx-linux-arm64-musl": "22.7.5", + "@nx/nx-linux-x64-gnu": "22.7.5", + "@nx/nx-linux-x64-musl": "22.7.5", + "@nx/nx-win32-arm64-msvc": "22.7.5", + "@nx/nx-win32-x64-msvc": "22.7.5" }, "peerDependencies": { "@swc-node/register": "^1.11.1", @@ -31071,9 +31072,9 @@ } }, "node_modules/nx/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -31404,19 +31405,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/nx/node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/nx/node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -37101,9 +37089,9 @@ "peer": true }, "node_modules/tmp": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", - "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", "dev": true, "license": "MIT", "engines": { @@ -37283,15 +37271,15 @@ } }, "node_modules/ts-checker-rspack-plugin": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.3.0.tgz", - "integrity": "sha512-89oK/BtApjdid1j9CGjPGiYry+EZBhsnTAM481/8ipgr/y2IOgCbW1HPnan+fs5FnzlpUgf9dWGNZ4Ayw3Bd8A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.3.1.tgz", + "integrity": "sha512-4VBjKblnJwypq+2aWZ9V65HENAmU/2s04d717YhLjC65MKitTTnqKeHE6GGB5C4S+2BnqZ9MtJt5AvS7nldaLQ==", "dev": true, "license": "MIT", "dependencies": { "@rspack/lite-tapable": "^1.1.0", "chokidar": "^3.6.0", - "memfs": "^4.56.10", + "memfs": "^4.57.2", "picocolors": "^1.1.1" }, "peerDependencies": { @@ -37339,14 +37327,14 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-core": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.2.tgz", - "integrity": "sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.3.tgz", + "integrity": "sha512-IvO50vkGydDZwS1e9rz/JXEtCCt9XvqxoGI6FlrVIvVm4/HpygMKW4ETtREWtMTsN5CLJ9FR6GuCduoQPZLBiw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", "thingies": "^2.5.0" }, "engines": { @@ -37361,15 +37349,15 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.2.tgz", - "integrity": "sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.3.tgz", + "integrity": "sha512-JlIDGUWPl7Y6zl+/ISnZuh8z2aMr/xoR66D18zlaVAuL192CvlNJEzOlzp27x4P52HRtDnCSOk6f59vTsmp5vw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-core": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", "thingies": "^2.5.0" }, "engines": { @@ -37384,17 +37372,17 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.2.tgz", - "integrity": "sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.3.tgz", + "integrity": "sha512-089gZoKvbeOsT2jeBaVKSz91oFXQWFG7a62sMY6gVMHnoWbyGzTb6OVUP/V7G3wLQLJ555BEsHt8SD1nj1dgaQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", - "@jsonjoy.com/fs-print": "4.57.2", - "@jsonjoy.com/fs-snapshot": "4.57.2", + "@jsonjoy.com/fs-core": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", + "@jsonjoy.com/fs-print": "4.57.3", + "@jsonjoy.com/fs-snapshot": "4.57.3", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, @@ -37410,9 +37398,9 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.2.tgz", - "integrity": "sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.3.tgz", + "integrity": "sha512-JAI3PqNuY8BR7ovy4h0bADLrqJLIcUauONNZfyTxUnj3Wf3tpTYe39eJ6z7FzYyA+tdMt33VpiQQUikGr3QOBw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -37427,15 +37415,15 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.2.tgz", - "integrity": "sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.3.tgz", + "integrity": "sha512-uZGxyC0zDmcmW5bfHd4YivAZ54BLlbF9G0K5rBaksI/tZdJSGM7/AC+1TY7yvFu0Wc6gUHR7mFwf6SbQ3J1BTQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2" + "@jsonjoy.com/fs-fsa": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3" }, "engines": { "node": ">=10.0" @@ -37449,13 +37437,13 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.2.tgz", - "integrity": "sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.3.tgz", + "integrity": "sha512-quCil8AvfcOxob4pn0drGdcQWpkPVgkt9q1+EjeyXXT40/L3l5lvYrr6hR8LmHu0eg+DNNaUwqjLT6Hr7V4sdQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.2" + "@jsonjoy.com/fs-node-builtins": "4.57.3" }, "engines": { "node": ">=10.0" @@ -37469,13 +37457,13 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-print": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.2.tgz", - "integrity": "sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.3.tgz", + "integrity": "sha512-ITwaLZpGIqD9jHndwMvDFZDIvbVzGRsJZDQ5HKln0vyMculu1c1nb7zbEBgY8BVSBZ9S2xO138OWIBGeRsrF3Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.3", "tree-dump": "^1.1.0" }, "engines": { @@ -37490,14 +37478,14 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.2.tgz", - "integrity": "sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.3.tgz", + "integrity": "sha512-wdNaG2DxCtvj9lKldAnEV3ycYPEpk+p2cP2lHD1qdxkoQGlWUtQverqvG9KZSkm6BHFha4PP6XRZbpARNfHRxA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.3", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -37640,20 +37628,20 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/memfs": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.2.tgz", - "integrity": "sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.3.tgz", + "integrity": "sha512-dlvqataP1zUOlfj6pv9wgCSC5pRIooNntXgdLfR7FWlcKi1p8fMfJADtHp/+8Dhu5JFvMHNh7L0QVcuaaBKqqA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.2", - "@jsonjoy.com/fs-fsa": "4.57.2", - "@jsonjoy.com/fs-node": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-to-fsa": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", - "@jsonjoy.com/fs-print": "4.57.2", - "@jsonjoy.com/fs-snapshot": "4.57.2", + "@jsonjoy.com/fs-core": "4.57.3", + "@jsonjoy.com/fs-fsa": "4.57.3", + "@jsonjoy.com/fs-node": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-to-fsa": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", + "@jsonjoy.com/fs-print": "4.57.3", + "@jsonjoy.com/fs-snapshot": "4.57.3", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -37772,9 +37760,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.7", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", - "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -37788,8 +37776,14 @@ "node": ">=12.0.0" }, "peerDependencies": { + "loader-utils": "*", "typescript": "*", - "webpack": "^5.0.0" + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } } }, "node_modules/ts-node": { @@ -39885,9 +39879,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", @@ -39940,12 +39934,11 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "devOptional": true, "license": "ISC", - "optional": true, - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 88750699e..4fa3e522a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.7.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -74,14 +74,14 @@ "@ionic/angular": "8.8.5", "@keyv/redis": "5.1.6", "@nestjs/bull": "11.0.4", - "@nestjs/cache-manager": "3.1.0", - "@nestjs/common": "11.1.19", + "@nestjs/cache-manager": "3.1.2", + "@nestjs/common": "11.1.21", "@nestjs/config": "4.0.4", - "@nestjs/core": "11.1.19", - "@nestjs/event-emitter": "3.0.1", + "@nestjs/core": "11.1.21", + "@nestjs/event-emitter": "3.1.0", "@nestjs/jwt": "11.0.2", "@nestjs/passport": "11.0.5", - "@nestjs/platform-express": "11.1.19", + "@nestjs/platform-express": "11.1.21", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "2.9.0", @@ -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": { @@ -156,17 +157,17 @@ "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", - "@nestjs/testing": "11.1.19", - "@nx/angular": "22.7.2", - "@nx/eslint-plugin": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/nest": "22.7.2", - "@nx/node": "22.7.2", - "@nx/storybook": "22.7.2", - "@nx/web": "22.7.2", - "@nx/workspace": "22.7.2", + "@nestjs/testing": "11.1.21", + "@nx/angular": "22.7.5", + "@nx/eslint-plugin": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/nest": "22.7.5", + "@nx/node": "22.7.5", + "@nx/storybook": "22.7.5", + "@nx/web": "22.7.5", + "@nx/workspace": "22.7.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -193,7 +194,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.7.2", + "nx": "22.7.5", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", diff --git a/tools/load-env.ts b/tools/load-env.ts new file mode 100644 index 000000000..3dd0d03c7 --- /dev/null +++ b/tools/load-env.ts @@ -0,0 +1,4 @@ +import { config } from 'dotenv'; +import { expand } from 'dotenv-expand'; + +expand(config({ path: process.env.GHOSTFOLIO_ENV_FILE, quiet: true }));