diff --git a/CHANGELOG.md b/CHANGELOG.md index 451390256..4f023da8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 3.11.0 - 2026-06-14 + +### Added + +- Added support for a click handler in the page tabs component + +### Changed + +- Improved the styling of the tabs across various dialogs +- Improved the styling of the page tabs component on desktop +- Enabled the _Bull Dashboard_ tab in the admin control panel (experimental) +- Migrated the settings dialog to customize the rule thresholds of the _X-ray_ page from `ngModel` to form control +- Improved the language localization for Spanish (`es`) +- Upgraded `bull-board` from version `7.1.5` to `7.2.1` +- Upgraded `date-fns` from version `4.1.0` to `4.4.0` + +### Fixed + +- Improved the loading state when customizing the rule thresholds on the _X-ray_ page + +## 3.10.0 - 2026-06-13 + +### Changed + +- Improved the dynamic numerical precision for various values in the account detail dialog on mobile +- Improved the dynamic numerical precision for various values in the holding detail dialog on mobile +- Improved the account name display in the activities table +- Optimized the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` by improving the processing of the historical market data + +### Fixed + +- Fixed an issue in the import dividends dialog +- Fixed an issue where certain symbols were incorrectly identified as currencies in various data providers +- Fixed the last request date in the users table of the admin control panel + +## 3.9.0 - 2026-06-12 + +### Added + +- Extended the _Public API_ with the endpoint to update the asset profile data (`PATCH api/v1/asset-profiles/:dataSource/:symbol`) (experimental) +- Added support for a dedicated _OpenRouter_ model for the `web_fetch` tool in the `FetchService` + +### Changed + +- Prefilled the form in the account balance management with the current cash balance +- Disabled the selection of future dates in the account balance management +- Grouped commodities and cryptocurrencies into the unknown bucket of the allocations by continent, country, currency, market and sector charts on the allocations page +- Moved the support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) in the assistant from experimental to general availability +- Migrated various components from `NgStyle` to style bindings +- Improved the language localization for Korean (`ko`) + +### Fixed + +- Grouped activities without an account into the unknown bucket of the allocations by account and platform charts on the allocations page + +## 3.8.0 - 2026-06-07 + +### Added + +- Added an automatic refresh every 30 seconds to the users table in the admin control panel + +### Changed + +- Harmonized the sector names across the data providers +- Localized the country names +- Localized the sector names +- Centralized the asset profile override logic for manual adjustments +- Improved the styling in the user detail dialog of the admin control panel’s users section +- 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 German (`de`) +- Improved the language localization for Ukrainian (`uk`) + +### Fixed + +- Prevented the floating action button from overlapping the paginator on mobile +- 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 panel by truncating long titles + ## 3.7.0 - 2026-06-02 ### Added diff --git a/README.md b/README.md index 8557d4330..270b65126 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,58 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_. } ``` +### Update Asset Profile Data (experimental) + +#### Prerequisites + +[Bearer Token](#authorization-bearer-token) for authorization with admin role + +#### Request + +`PATCH http://localhost:3333/api/v1/asset-profiles//` + +#### Body + +``` +{ + "countries": [ + { + "code": "US", + "weight": 1 + } + ], + "sectors": [ + { + "name": "Technology", + "weight": 1 + } + ] +} +``` + +| Field | Type | Description | +| ----------- | ------------------ | ---------------------------------------------------------------------- | +| `countries` | `array` (optional) | Countries with `code` (`ISO 3166-1 alpha-2`) and `weight` (`0` to `1`) | +| `holdings` | `array` (optional) | Holdings with `name` and `weight` (`0` to `1`) | +| `sectors` | `array` (optional) | Sectors with `name` and `weight` (`0` to `1`) | + +#### Response + +##### Success + +`200 OK` + +##### Error + +`404 Not Found` + +``` +{ + "error": "Not Found", + "message": "Could not find the asset profile for MSFT (YAHOO)" +} +``` + ## Community Projects Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index f4e592c3f..661163ff1 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -6,9 +6,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; @@ -23,11 +25,13 @@ import { ActivitiesService } from './activities.service'; exports: [ActivitiesService], imports: [ ApiModule, + BenchmarkModule, CacheModule, DataGatheringQueueModule, DataProviderModule, ExchangeRateDataModule, ImpersonationModule, + MarketDataModule, PrismaModule, RedactValuesInResponseModule, RedisCacheModule, diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 821185e11..f57507e3d 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -4,8 +4,10 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; @@ -16,7 +18,10 @@ import { ghostfolioPrefix, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; -import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + canDeleteAssetProfile, + getAssetProfileIdentifier +} from '@ghostfolio/common/helper'; import { ActivitiesResponse, Activity, @@ -48,10 +53,12 @@ export class ActivitiesService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService ) {} @@ -262,7 +269,26 @@ export class ActivitiesService { activity.symbolProfileId ]); - if (symbolProfile.activitiesCount === 0) { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + const isBenchmark = benchmarkAssetProfiles.some(({ id }) => { + return id === symbolProfile.id; + }); + + if ( + canDeleteAssetProfile({ + isBenchmark, + activitiesCount: symbolProfile.activitiesCount, + symbol: symbolProfile.symbol, + watchedByCount: symbolProfile.watchedByCount + }) + ) { + await this.marketDataService.deleteMany({ + dataSource: symbolProfile.dataSource, + symbol: symbolProfile.symbol + }); + await this.symbolProfileService.deleteById(activity.symbolProfileId); } @@ -308,8 +334,31 @@ export class ActivitiesService { }) ); - for (const { activitiesCount, id } of symbolProfiles) { - if (activitiesCount === 0) { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + for (const { + activitiesCount, + dataSource, + id, + symbol, + watchedByCount + } of symbolProfiles) { + const isBenchmark = benchmarkAssetProfiles.some( + (benchmarkAssetProfile) => { + return benchmarkAssetProfile.id === id; + } + ); + + if ( + canDeleteAssetProfile({ + activitiesCount, + isBenchmark, + symbol, + watchedByCount + }) + ) { + await this.marketDataService.deleteMany({ dataSource, symbol }); await this.symbolProfileService.deleteById(id); } } diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 69b619625..1a2c58d1c 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -2,9 +2,11 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, @@ -16,7 +18,10 @@ import { UpdateAssetProfileDto, UpdatePropertyDto } from '@ghostfolio/common/dtos'; -import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + canDeleteAssetProfile, + getAssetProfileIdentifier +} from '@ghostfolio/common/helper'; import { AdminData, AdminMarketData, @@ -58,13 +63,17 @@ 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, + private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly demoService: DemoService, private readonly manualService: ManualService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly symbolProfileService: SymbolProfileService ) {} @Get() @@ -260,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); } @@ -288,6 +297,33 @@ export class AdminController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (assetProfile) { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + const isBenchmark = benchmarkAssetProfiles.some(({ id }) => { + return id === assetProfile.id; + }); + + if ( + !canDeleteAssetProfile({ + isBenchmark, + activitiesCount: assetProfile.activitiesCount, + symbol: assetProfile.symbol, + watchedByCount: assetProfile.watchedByCount + }) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } + return this.adminService.deleteProfileData({ dataSource, symbol }); } diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 0bf5c3925..be6f050c4 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) { @@ -619,6 +593,7 @@ export class AdminService { assetClass: assetClass as AssetClass, assetSubClass: assetSubClass as AssetSubClass, countries: countries as Prisma.JsonArray, + holdings: holdings as Prisma.JsonArray, name: name as string, sectors: sectors as Prisma.JsonArray, url: url as string @@ -628,21 +603,14 @@ export class AdminService { comment, currency, dataSource, - holdings, isActive, scraperConfiguration, symbol, symbolMapping, - ...(dataSource === 'MANUAL' - ? { assetClass, assetSubClass, countries, name, sectors, url } - : { - SymbolProfileOverrides: { - upsert: { - create: symbolProfileOverrides, - update: symbolProfileOverrides - } - } - }) + ...this.symbolProfileService.getAssetProfileUpdateInput( + { dataSource, symbol }, + symbolProfileOverrides + ) }; await this.symbolProfileService.updateSymbolProfile( @@ -882,7 +850,7 @@ export class AdminService { activityCount: true, country: true, dataProviderGhostfolioDailyRequests: true, - updatedAt: true + lastRequestAt: true } }, createdAt: true, @@ -928,7 +896,7 @@ export class AdminService { activityCount: _count.activities || 0, country: analytics?.country, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, - lastActivity: analytics?.updatedAt + lastActivity: analytics?.lastRequestAt }; } ); diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4857c7e14..0a27faa64 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -38,6 +38,7 @@ import { AuthModule } from './auth/auth.module'; import { CacheModule } from './cache/cache.module'; import { AiModule } from './endpoints/ai/ai.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; +import { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module'; import { AssetsModule } from './endpoints/assets/assets.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; @@ -69,6 +70,7 @@ import { UserModule } from './user/user.module'; ActivitiesModule, AiModule, ApiKeysModule, + AssetProfilesModule, AssetModule, AssetsModule, AuthDeviceModule, diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index f55093bbf..1d6990307 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -50,6 +50,8 @@ import { OidcStrategy } from './oidc.strategy'; configurationService: ConfigurationService, fetchService: FetchService ) => { + const logger = new Logger('OidcStrategy'); + const isOidcEnabled = configurationService.get( 'ENABLE_FEATURE_AUTH_OIDC' ); @@ -101,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/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts new file mode 100644 index 000000000..38227c555 --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -0,0 +1,51 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; +import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + HttpException, + Inject, + Param, + Patch, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { AssetProfilesService } from './asset-profiles.service'; + +@Controller('asset-profiles') +export class AssetProfilesController { + public constructor( + private readonly assetProfilesService: AssetProfilesService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @HasPermission(permissions.accessAdminControl) + @Patch(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAssetProfileData( + @Body() assetProfileData: UpdateAssetProfileDataDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + if (!this.request.user.settings.settings.isExperimentalFeatures) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return this.assetProfilesService.updateAssetProfileData( + { dataSource, symbol }, + assetProfileData + ); + } +} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts new file mode 100644 index 000000000..32b9ab393 --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts @@ -0,0 +1,13 @@ +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { AssetProfilesController } from './asset-profiles.controller'; +import { AssetProfilesService } from './asset-profiles.service'; + +@Module({ + controllers: [AssetProfilesController], + imports: [SymbolProfileModule], + providers: [AssetProfilesService] +}) +export class AssetProfilesModule {} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts new file mode 100644 index 000000000..ef24372af --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -0,0 +1,90 @@ +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; +import { + AssetProfileIdentifier, + EnhancedSymbolProfile +} from '@ghostfolio/common/interfaces'; + +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class AssetProfilesService { + public constructor( + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async updateAssetProfileData( + { dataSource, symbol }: AssetProfileIdentifier, + assetProfileData: UpdateAssetProfileDataDto + ): Promise { + const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`; + + const data = this.getAssetProfileDataUpdate(assetProfileData); + + if (Object.keys(data).length > 0) { + try { + await this.symbolProfileService.updateSymbolProfile( + { + dataSource, + symbol + }, + this.symbolProfileService.getAssetProfileUpdateInput( + { dataSource, symbol }, + data + ) + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new NotFoundException(notFoundMessage); + } + + throw error; + } + } + + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]); + + if (!assetProfile) { + throw new NotFoundException(notFoundMessage); + } + + return assetProfile; + } + + private getAssetProfileDataUpdate({ + countries, + holdings, + sectors + }: UpdateAssetProfileDataDto): Pick< + Prisma.SymbolProfileUpdateInput, + 'countries' | 'holdings' | 'sectors' + > { + const data: Pick< + Prisma.SymbolProfileUpdateInput, + 'countries' | 'holdings' | 'sectors' + > = {}; + + if (countries !== undefined) { + data.countries = countries as Prisma.JsonArray; + } + + if (holdings !== undefined) { + data.holdings = holdings as Prisma.JsonArray; + } + + if (sectors !== undefined) { + data.sectors = sectors as Prisma.JsonArray; + } + + return data; + } +} 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.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index 3f91dbecc..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 @@ -34,6 +34,8 @@ 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, @@ -99,7 +101,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -141,7 +143,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -183,7 +185,7 @@ export class GhostfolioService { return result; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -271,7 +273,7 @@ export class GhostfolioService { return results; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } @@ -348,7 +350,7 @@ export class GhostfolioService { return results; } catch (error) { - Logger.error(error, 'GhostfolioService'); + this.logger.error(error); throw error; } 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/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-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 217a67c49..2d85330a3 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -116,10 +116,12 @@ describe('PortfolioCalculator', () => { accountBalanceService, accountService, null, + null, dataProviderService, null, exchangeRateDataService, null, + null, null ); 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.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index da846c45d..e0e7a8255 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -9,6 +9,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data 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 { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; import { Account, DataSource } from '@prisma/client'; @@ -59,10 +60,12 @@ describe('PortfolioService', () => { null, accountService, null, + null, dataProviderService, null, exchangeRateDataService, null, + null, null ); @@ -106,6 +109,67 @@ describe('PortfolioService', () => { ); }); + describe('getAggregatedMarkets', () => { + const getAggregatedMarkets = (holdings: object) => { + return ( + portfolioService as unknown as { + getAggregatedMarkets: (aHoldings: object) => { + markets: Record< + string, + { valueInBaseCurrency: number; valueInPercentage: number } + >; + marketsAdvanced: Record; + }; + } + ).getAggregatedMarkets(holdings); + }; + + it('should distribute holdings with countries to their market and route holdings without countries (e.g. commodities, cryptocurrencies) to the unknown bucket', () => { + const holdings = { + 'GC=F': { + // Gold + assetProfile: { countries: [] }, + markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }, + marketsAdvanced: { + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + valueInBaseCurrency: 500 + }, + MSFT: { + assetProfile: { countries: [{ code: 'US', weight: 1 }] }, + markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 }, + marketsAdvanced: { + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + valueInBaseCurrency: 1000 + } + }; + + const { markets, marketsAdvanced } = getAggregatedMarkets(holdings); + + expect(markets.developedMarkets.valueInBaseCurrency).toBe(1000); + expect(markets[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); + + expect(markets.developedMarkets.valueInPercentage).toBeCloseTo( + 1000 / 1500 + ); + expect(markets[UNKNOWN_KEY].valueInPercentage).toBeCloseTo(500 / 1500); + + expect(marketsAdvanced.northAmerica.valueInBaseCurrency).toBe(1000); + expect(marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); + }); + }); + describe('getCashSymbolProfiles', () => { it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { jest @@ -269,4 +333,96 @@ describe('PortfolioService', () => { expect(holdings['USD'].assetProfile.symbol).toBe('USD'); }); }); + + describe('getValueOfAccountsAndPlatforms', () => { + const getValueOfAccountsAndPlatforms = (args: object) => { + return ( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ + accounts: Record; + platforms: Record; + }>; + } + ).getValueOfAccountsAndPlatforms(args); + }; + + const account = { + balance: 100, + currency: 'USD', + id: randomUUID(), + isExcluded: false, + name: 'Account 1', + platform: { name: 'Platform 1' }, + platformId: randomUUID() + }; + + beforeEach(() => { + jest + .spyOn(accountService, 'getAccounts') + .mockResolvedValue([account] as unknown as Account[]); + + jest + .spyOn(exchangeRateDataService, 'toCurrency') + .mockImplementation((aValue) => aValue); + }); + + it('should group activities without an account into the unknown bucket of accounts and platforms', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + quantity: 1, + SymbolProfile: { symbol: 'AAPL' }, + type: 'BUY' + }, + { + account: null, + accountId: null, + quantity: 2, + SymbolProfile: { symbol: 'BABA' }, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 }, + BABA: { marketPriceInBaseCurrency: 20 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 1 * 10 (activity) + expect(accounts[account.id].valueInBaseCurrency).toBe(110); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); + + // 2 * 20 (activity without an account) + expect(accounts[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); + expect(platforms[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); + }); + + it('should not create an unknown bucket when every activity has an account', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + quantity: 1, + SymbolProfile: { symbol: 'AAPL' }, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[UNKNOWN_KEY]).toBeUndefined(); + expect(platforms[UNKNOWN_KEY]).toBeUndefined(); + }); + }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 37d76bcfa..24d760888 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, @@ -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; @@ -1452,31 +1453,29 @@ export class PortfolioService { for (const [, position] of Object.entries(holdings)) { const value = position.valueInBaseCurrency; - if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { - if (position.assetProfile.countries.length > 0) { - markets.developedMarkets.valueInBaseCurrency += - position.markets.developedMarkets * value; - markets.emergingMarkets.valueInBaseCurrency += - position.markets.emergingMarkets * value; - markets.otherMarkets.valueInBaseCurrency += - position.markets.otherMarkets * value; - - marketsAdvanced.asiaPacific.valueInBaseCurrency += - position.marketsAdvanced.asiaPacific * value; - marketsAdvanced.emergingMarkets.valueInBaseCurrency += - position.marketsAdvanced.emergingMarkets * value; - marketsAdvanced.europe.valueInBaseCurrency += - position.marketsAdvanced.europe * value; - marketsAdvanced.japan.valueInBaseCurrency += - position.marketsAdvanced.japan * value; - marketsAdvanced.northAmerica.valueInBaseCurrency += - position.marketsAdvanced.northAmerica * value; - marketsAdvanced.otherMarkets.valueInBaseCurrency += - position.marketsAdvanced.otherMarkets * value; - } else { - markets[UNKNOWN_KEY].valueInBaseCurrency += value; - marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; - } + if (position.assetProfile.countries.length > 0) { + markets.developedMarkets.valueInBaseCurrency += + position.markets.developedMarkets * value; + markets.emergingMarkets.valueInBaseCurrency += + position.markets.emergingMarkets * value; + markets.otherMarkets.valueInBaseCurrency += + position.markets.otherMarkets * value; + + marketsAdvanced.asiaPacific.valueInBaseCurrency += + position.marketsAdvanced.asiaPacific * value; + marketsAdvanced.emergingMarkets.valueInBaseCurrency += + position.marketsAdvanced.emergingMarkets * value; + marketsAdvanced.europe.valueInBaseCurrency += + position.marketsAdvanced.europe * value; + marketsAdvanced.japan.valueInBaseCurrency += + position.marketsAdvanced.japan * value; + marketsAdvanced.northAmerica.valueInBaseCurrency += + position.marketsAdvanced.northAmerica * value; + marketsAdvanced.otherMarkets.valueInBaseCurrency += + position.marketsAdvanced.otherMarkets * value; + } else { + markets[UNKNOWN_KEY].valueInBaseCurrency += value; + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; } } @@ -2162,40 +2161,44 @@ export class PortfolioService { return withExcludedAccounts || account.isExcluded === false; }); - for (const account of currentAccounts) { + // Iterate over the accounts plus a null entry to group activities without + // an account into the unknown bucket + for (const account of [...currentAccounts, null]) { const ordersByAccount = activities.filter(({ accountId }) => { - return accountId === account.id; + return account ? accountId === account.id : !accountId; }); - accounts[account.id] = { - balance: account.balance, - currency: account.currency, - name: account.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) - }; - - if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { - platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += - this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ); - } else { - platforms[account.platformId || UNKNOWN_KEY] = { + if (account) { + accounts[account.id] = { balance: account.balance, currency: account.currency, - name: account.platform?.name, + name: account.name, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( account.balance, account.currency, userCurrency ) }; + + if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { + platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += + this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + } else { + platforms[account.platformId || UNKNOWN_KEY] = { + balance: account.balance, + currency: account.currency, + name: account.platform?.name, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ) + }; + } } for (const { 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/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/helper/country.helper.ts b/apps/api/src/helper/country.helper.ts new file mode 100644 index 000000000..9d14d8778 --- /dev/null +++ b/apps/api/src/helper/country.helper.ts @@ -0,0 +1,17 @@ +import { countries } from 'countries-list'; + +export function getCountryCodeByName({ + aliases = {}, + name +}: { + aliases?: Record; + name: string; +}): string { + for (const [code, country] of Object.entries(countries)) { + if (country.name === name || country.name === aliases[name]) { + return code; + } + } + + return undefined; +} diff --git a/apps/api/src/helper/sector.helper.ts b/apps/api/src/helper/sector.helper.ts new file mode 100644 index 000000000..e0face386 --- /dev/null +++ b/apps/api/src/helper/sector.helper.ts @@ -0,0 +1,28 @@ +import { SECTORS } from '@ghostfolio/common/config'; +import { SectorName } from '@ghostfolio/common/types'; + +import { Logger } from '@nestjs/common'; + +export function getSectorName({ + aliases = {}, + name +}: { + aliases?: Record; + name: string; +}): SectorName { + if (aliases[name]) { + return aliases[name]; + } + + if ((SECTORS as readonly string[]).includes(name)) { + return name as SectorName; + } + + if (name) { + const logger = new Logger('getSectorName'); + + logger.warn(`Could not map the sector "${name}" to the ontology`); + } + + return 'Other'; +} 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 94e389f6a..63185a48b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -23,6 +23,8 @@ 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()); @@ -114,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/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index b19508d3e..5f9d1055d 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -30,7 +30,6 @@ export class ConfigurationService { API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }), - BULL_BOARD_IS_READ_ONLY: bool({ default: true }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), 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 b01ba177b..5d6ed79aa 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -29,6 +29,8 @@ 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 = {}; @@ -88,7 +90,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return response; @@ -214,7 +216,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit { ).toFixed(3)} seconds`; } - Logger.error(message, 'CoinGeckoService'); + this.logger.error(message); } return response; @@ -262,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/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index eeccf725e..c8291a901 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,13 +1,15 @@ +import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper'; +import { getSectorName } from '@ghostfolio/api/helper/sector.helper'; 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'; +import { SectorName } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; -import { countries } from 'countries-list'; @Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { @@ -17,13 +19,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { USA: 'United States' }; private static holdingsWeightTreshold = 0.85; - private static sectorsMapping = { + private static sectorsMapping: Record = { 'Consumer Discretionary': 'Consumer Cyclical', - 'Consumer Defensive': 'Consumer Staples', + 'Consumer Staples': 'Consumer Defensive', + Financials: 'Financial Services', 'Health Care': 'Healthcare', - 'Information Technology': 'Technology' + 'Information Technology': 'Technology', + Materials: 'Basic Materials' }; + private readonly logger = new Logger(TrackinsightDataEnhancerService.name); + public constructor( private readonly configurationService: ConfigurationService, private readonly fetchService: FetchService @@ -115,21 +121,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { for (const [name, value] of Object.entries( holdings?.countries ?? {} )) { - let countryCode: string; - - for (const [code, country] of Object.entries(countries)) { - if ( - country.name === name || - country.name === - TrackinsightDataEnhancerService.countriesMapping[name] - ) { - countryCode = code; - break; - } - } - response.countries.push({ - code: countryCode, + code: getCountryCodeByName({ + name, + aliases: TrackinsightDataEnhancerService.countriesMapping + }), weight: value.weight }); } @@ -163,7 +159,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { holdings?.sectors ?? {} )) { response.sectors.push({ - name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, + name: getSectorName({ + name, + aliases: TrackinsightDataEnhancerService.sectorsMapping + }), weight: value.weight }); } @@ -209,9 +208,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..85ec6c020 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 @@ -1,12 +1,13 @@ +import { getSectorName } from '@ghostfolio/api/helper/sector.helper'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DEFAULT_CURRENCY, - REPLACE_NAME_PARTS, - UNKNOWN_KEY + REPLACE_NAME_PARTS } from '@ghostfolio/common/config'; -import { isCurrency } from '@ghostfolio/common/helper'; +import { isCurrencySymbol } from '@ghostfolio/common/helper'; +import { SectorName } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { @@ -23,6 +24,22 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; @Injectable() export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + private static sectorsMapping: Record = { + basic_materials: 'Basic Materials', + communication_services: 'Communication Services', + consumer_cyclical: 'Consumer Cyclical', + consumer_defensive: 'Consumer Defensive', + energy: 'Energy', + financial_services: 'Financial Services', + healthcare: 'Healthcare', + industrials: 'Industrials', + realestate: 'Real Estate', + technology: 'Technology', + utilities: 'Utilities' + }; + + private readonly logger = new Logger(YahooFinanceDataEnhancerService.name); + private readonly yahooFinance = new YahooFinance({ suppressNotices: ['yahooSurvey'] }); @@ -56,31 +73,21 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { * DOGEUSD -> DOGE-USD */ public convertToYahooFinanceSymbol(aSymbol: string) { - if ( - aSymbol.includes(DEFAULT_CURRENCY) && - aSymbol.length > DEFAULT_CURRENCY.length + if (isCurrencySymbol(aSymbol)) { + return `${aSymbol}=X`; + } else if ( + this.cryptocurrencyService.isCryptocurrency( + aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY) + ) ) { - if ( - isCurrency( - aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) - ) && - isCurrency(aSymbol.substring(aSymbol.length - DEFAULT_CURRENCY.length)) - ) { - return `${aSymbol}=X`; - } else if ( - this.cryptocurrencyService.isCryptocurrency( - aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY) - ) - ) { - // Add a dash before the last three characters - // BTCUSD -> BTC-USD - // DOGEUSD -> DOGE-USD - // SOL1USD -> SOL1-USD - return aSymbol.replace( - new RegExp(`-?${DEFAULT_CURRENCY}$`), - `-${DEFAULT_CURRENCY}` - ); - } + // Add a dash before the last three characters + // BTCUSD -> BTC-USD + // DOGEUSD -> DOGE-USD + // SOL1USD -> SOL1-USD + return aSymbol.replace( + new RegExp(`-?${DEFAULT_CURRENCY}$`), + `-${DEFAULT_CURRENCY}` + ); } return aSymbol; @@ -123,7 +130,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { response.url = url; } } catch (error) { - Logger.error(error, 'YahooFinanceDataEnhancerService'); + this.logger.error(error); } return response; @@ -222,7 +229,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { .flatMap((sectorWeighting) => { return Object.entries(sectorWeighting).map(([sector, weight]) => { return { - name: this.parseSector(sector), + name: getSectorName({ + aliases: YahooFinanceDataEnhancerService.sectorsMapping, + name: sector + }), weight: weight as number }; }); @@ -266,7 +276,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { `No data found, ${aSymbol} (${this.getName()}) may be delisted` ); } else { - Logger.error(error, 'YahooFinanceService'); + this.logger.error(error); } } @@ -329,46 +339,4 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { return { assetClass, assetSubClass }; } - - private parseSector(aString: string) { - let sector = UNKNOWN_KEY; - - switch (aString) { - case 'basic_materials': - sector = 'Basic Materials'; - break; - case 'communication_services': - sector = 'Communication Services'; - break; - case 'consumer_cyclical': - sector = 'Consumer Cyclical'; - break; - case 'consumer_defensive': - sector = 'Consumer Staples'; - break; - case 'energy': - sector = 'Energy'; - break; - case 'financial_services': - sector = 'Financial Services'; - break; - case 'healthcare': - sector = 'Healthcare'; - break; - case 'industrials': - sector = 'Industrials'; - break; - case 'realestate': - sector = 'Real Estate'; - break; - case 'technology': - sector = 'Technology'; - break; - case 'utilities': - sector = 'Utilities'; - break; - } - - return sector; - } } 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..5b54afb0b 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; } @@ -383,15 +385,16 @@ export class DataProviderService implements OnModuleInit { response = marketDataByGranularity.reduce((r, marketData) => { const { date, marketPrice, symbol } = marketData; - r[symbol] = { - ...(r[symbol] || {}), - [format(new Date(date), DATE_FORMAT)]: { marketPrice } - }; + if (!r[symbol]) { + r[symbol] = {}; + } + + r[symbol][format(new Date(date), DATE_FORMAT)] = { marketPrice }; return r; }, {}); } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); } finally { return response; } @@ -503,7 +506,7 @@ export class DataProviderService implements OnModuleInit { result[symbol] = data; } } catch (error) { - Logger.error(error, 'DataProviderService'); + this.logger.error(error); throw error; } @@ -567,13 +570,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 +686,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 +723,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 3fa38842b..ebb6cd743 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 @@ -13,7 +13,7 @@ import { DEFAULT_CURRENCY, REPLACE_NAME_PARTS } from '@ghostfolio/common/config'; -import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; +import { DATE_FORMAT, isCurrencySymbol } from '@ghostfolio/common/helper'; import { DataProviderHistoricalResponse, DataProviderInfo, @@ -37,6 +37,8 @@ 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'; @@ -127,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 {}; @@ -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}` ); } @@ -292,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()})` ); } } @@ -311,7 +310,7 @@ export class EodHistoricalDataService ).toFixed(3)} seconds`; } - Logger.error(message, 'EodHistoricalDataService'); + this.logger.error(message); } return {}; @@ -383,20 +382,11 @@ export class EodHistoricalDataService * Currency: USDCHF -> USDCHF.FOREX */ private convertToEodSymbol(aSymbol: string) { - if ( - aSymbol.startsWith(DEFAULT_CURRENCY) && - aSymbol.length > DEFAULT_CURRENCY.length - ) { - if ( - isCurrency( - aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) - ) - ) { - let symbol = aSymbol; - symbol = symbol.replace('GBp', 'GBX'); + if (isCurrencySymbol(aSymbol)) { + let symbol = aSymbol; + symbol = symbol.replace('GBp', 'GBX'); - return `${symbol}.FOREX`; - } + return `${symbol}.FOREX`; } return aSymbol; @@ -465,7 +455,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 fa36a0d17..ca48bb247 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 @@ -1,3 +1,4 @@ +import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; @@ -15,7 +16,11 @@ import { DEFAULT_CURRENCY, REPLACE_NAME_PARTS } from '@ghostfolio/common/config'; -import { DATE_FORMAT, isCurrency, parseDate } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + isCurrencySymbol, + parseDate +} from '@ghostfolio/common/helper'; import { DataProviderHistoricalResponse, DataProviderInfo, @@ -33,7 +38,6 @@ import { SymbolProfile } from '@prisma/client'; import { isISIN } from 'class-validator'; -import { countries } from 'countries-list'; import { addDays, addYears, @@ -55,6 +59,8 @@ export class FinancialModelingPrepService 'Taiwan (Province of China)': 'Taiwan' }; + private readonly logger = new Logger(FinancialModelingPrepService.name); + private apiKey: string; public constructor( @@ -84,9 +90,7 @@ export class FinancialModelingPrepService }; try { - if ( - isCurrency(symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)) - ) { + if (isCurrencySymbol(symbol)) { response.assetClass = AssetClass.LIQUIDITY; response.assetSubClass = AssetSubClass.CASH; response.currency = symbol.substring( @@ -163,21 +167,11 @@ export class FinancialModelingPrepService return countryName.toLowerCase() !== 'other'; }) .map(({ country: countryName, weightPercentage }) => { - let countryCode: string; - - for (const [code, country] of Object.entries(countries)) { - if ( - country.name === countryName || - country.name === - FinancialModelingPrepService.countriesMapping[countryName] - ) { - countryCode = code; - break; - } - } - return { - code: countryCode, + code: getCountryCodeByName({ + aliases: FinancialModelingPrepService.countriesMapping, + name: countryName + }), weight: parseFloat(weightPercentage.slice(0, -1)) / 100 }; }); @@ -265,7 +259,7 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + this.logger.error(message); } return response; @@ -325,12 +319,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 {}; @@ -491,11 +484,7 @@ export class FinancialModelingPrepService for (const { price, symbol } of quotes) { let marketState: MarketState = 'delayed'; - if ( - isCurrency( - symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length) - ) - ) { + if (isCurrencySymbol(symbol)) { marketState = 'open'; } @@ -518,7 +507,7 @@ export class FinancialModelingPrepService ).toFixed(3)} seconds`; } - Logger.error(message, 'FinancialModelingPrepService'); + this.logger.error(message); } return response; @@ -638,7 +627,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 2f2601d5d..2b91855a6 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -33,6 +33,8 @@ 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`; @@ -89,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; @@ -154,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; @@ -211,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( @@ -283,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; @@ -338,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 11e0aae6a..87e116dda 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -31,6 +31,8 @@ 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, @@ -181,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 }; } @@ -216,7 +217,7 @@ export class ManualService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'ManualService'); + this.logger.error(error); } return {}; 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 22896cccc..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 @@ -26,6 +26,8 @@ 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 fetchService: FetchService @@ -122,7 +124,7 @@ export class RapidApiService implements DataProviderInterface { }; } } catch (error) { - Logger.error(error, 'RapidApiService'); + this.logger.error(error); } return {}; @@ -167,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.service.ts b/apps/api/src/services/fetch/fetch.service.ts index f32e56a1c..2425e476e 100644 --- a/apps/api/src/services/fetch/fetch.service.ts +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -3,6 +3,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { PROPERTY_API_KEY_OPENROUTER, PROPERTY_OPENROUTER_MODEL, + PROPERTY_OPENROUTER_MODEL_WEB_FETCH, PROPERTY_WEB_FETCH_ROUTES } from '@ghostfolio/common/config'; @@ -15,6 +16,8 @@ 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'); @@ -39,7 +42,7 @@ export class FetchService implements OnModuleInit { const url = input instanceof Request ? input.url : input.toString(); const urlRedacted = this.redactUrl(url); - Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); + this.logger.debug(`${method} ${urlRedacted}`); if (method === 'GET') { const webFetchRoute = this.getMatchingWebFetchRoute(url); @@ -60,15 +63,11 @@ export class FetchService implements OnModuleInit { return await globalThis.fetch(input, init); } catch (error) { if (error instanceof Error) { - Logger.error( - `${method} ${urlRedacted} failed: [${error.name}] ${error.message}`, - 'FetchService' + this.logger.error( + `${method} ${urlRedacted} failed: [${error.name}] ${error.message}` ); } else { - Logger.error( - `${method} ${urlRedacted} failed: ${String(error)}`, - 'FetchService' - ); + this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`); } throw error; @@ -82,12 +81,18 @@ export class FetchService implements OnModuleInit { 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) { + const [openRouterApiKey, openRouterModel, openRouterModelWebFetch] = + await Promise.all([ + this.propertyService.getByKey(PROPERTY_API_KEY_OPENROUTER), + this.propertyService.getByKey(PROPERTY_OPENROUTER_MODEL), + this.propertyService.getByKey( + PROPERTY_OPENROUTER_MODEL_WEB_FETCH + ) + ]); + + const model = openRouterModelWebFetch || openRouterModel; + + if (!model || !openRouterApiKey) { return undefined; } @@ -95,7 +100,7 @@ export class FetchService implements OnModuleInit { const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); const { sources, text } = await generateText({ - model: openRouterService.chat(openRouterModel), + model: openRouterService.chat(model), 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.', @@ -145,10 +150,7 @@ export class FetchService implements OnModuleInit { } } - Logger.debug( - `Routed ${this.redactUrl(url)} via web fetch tool`, - 'FetchService' - ); + this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`); return new Response(body, { headers: webFetchRoute.responseContentType @@ -159,11 +161,10 @@ export class FetchService implements OnModuleInit { return undefined; } catch (error) { - Logger.error( + this.logger.error( `Web fetch tool failed for ${this.redactUrl(url)}: ${ error instanceof Error ? error.message : String(error) - }`, - 'FetchService' + }` ); return undefined; 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/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index eb3ac86a3..57c58898e 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -10,7 +10,6 @@ export interface Environment extends CleanedEnvAccessors { API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_OPEN_FIGI: string; API_KEY_RAPID_API: string; - BULL_BOARD_IS_READ_ONLY: boolean; CACHE_QUOTES_TTL: number; CACHE_TTL: number; DATA_SOURCE_EXCHANGE_RATES: string; 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.module.ts b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts index 5672df5e8..5ac6c40c0 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts @@ -23,8 +23,7 @@ import { DataGatheringProcessor } from './data-gathering.processor'; adapter: BullAdapter, name: DATA_GATHERING_QUEUE, options: { - displayName: 'Data Gathering', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + displayName: 'Data Gathering' } }), BullModule.registerQueue({ 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.module.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts index c90f826f6..0da529821 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts @@ -29,8 +29,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; adapter: BullAdapter, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, options: { - displayName: 'Portfolio Snapshot Computation', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + displayName: 'Portfolio Snapshot Computation' } }), BullModule.registerQueue({ 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 d6f6d5ccd..6ef14e29c 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 @@ -20,8 +20,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service'; adapter: BullAdapter, name: STATISTICS_GATHERING_QUEUE, options: { - displayName: 'Statistics Gathering', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + displayName: 'Statistics Gathering' } }) ] 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 a523ef4f2..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 @@ -27,6 +27,8 @@ 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, @@ -35,10 +37,7 @@ export class StatisticsGatheringProcessor { @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(); @@ -47,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(); @@ -67,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(); @@ -87,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' ); } @@ -100,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); @@ -120,10 +106,7 @@ 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 { @@ -139,7 +122,7 @@ export class StatisticsGatheringProcessor { return pull_count; } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - DockerHub'); + this.logger.error(error); throw error; } @@ -169,7 +152,7 @@ export class StatisticsGatheringProcessor { value }); } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); + this.logger.error(error); throw error; } @@ -188,7 +171,7 @@ export class StatisticsGatheringProcessor { return stargazers_count; } catch (error) { - Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); + this.logger.error(error); throw error; } @@ -217,7 +200,7 @@ export class StatisticsGatheringProcessor { 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..2d5116274 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, @@ -10,7 +11,12 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; -import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; +import { + DataSource, + Prisma, + SymbolProfile, + SymbolProfileOverrides +} from '@prisma/client'; import { continents, countries } from 'countries-list'; @Injectable() @@ -70,6 +76,26 @@ export class SymbolProfileService { }); } + public getAssetProfileUpdateInput( + { dataSource }: AssetProfileIdentifier, + data: Prisma.SymbolProfileUpdateInput + ): Prisma.SymbolProfileUpdateInput { + if (dataSource === DataSource.MANUAL) { + return data; + } + + return { + SymbolProfileOverrides: { + upsert: { + create: + data as Prisma.SymbolProfileOverridesCreateWithoutSymbolProfileInput, + update: + data as Prisma.SymbolProfileOverridesUpdateWithoutSymbolProfileInput + } + } + }; + } + public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { @@ -192,21 +218,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 +250,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/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index d9b279040..7cdf3e671 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_DATE_RANGE, DEFAULT_PAGE_SIZE, - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_5_FIGURES } from '@ghostfolio/common/config'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; @@ -245,7 +245,7 @@ export class GfAccountDetailDialogComponent implements OnInit { this.balance = balance; if ( - this.balance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && + this.balance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES && this.data.deviceType === 'mobile' ) { this.balancePrecision = 0; @@ -257,7 +257,7 @@ export class GfAccountDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.dividendInBaseCurrency >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.dividendInBaseCurrencyPrecision = 0; } @@ -267,7 +267,7 @@ export class GfAccountDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && - this.equity >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.equity >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.equityPrecision = 0; } @@ -280,7 +280,7 @@ export class GfAccountDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.interestInBaseCurrency >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.interestInBaseCurrencyPrecision = 0; } diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index cd397e35e..4b652db96 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -148,6 +148,7 @@ [accountBalances]="accountBalances" [accountCurrency]="currency" [accountId]="data.accountId" + [currentBalance]="balance" [locale]="user?.settings?.locale" [showActions]=" !data.hasImpersonationId && diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts b/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts index b4c228881..fd90bff2c 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.component.ts @@ -1,8 +1,5 @@ -import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { - BULL_BOARD_COOKIE_NAME, - BULL_BOARD_ROUTE, DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, @@ -10,7 +7,6 @@ import { } from '@ghostfolio/common/config'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { AdminJobs, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { AdminService } from '@ghostfolio/ui/services'; @@ -106,7 +102,6 @@ export class GfAdminJobsComponent implements OnInit { 'actions' ]; - protected hasPermissionToAccessBullBoard = false; protected isLoading = false; protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST; @@ -116,7 +111,6 @@ export class GfAdminJobsComponent implements OnInit { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly notificationService = inject(NotificationService); - private readonly tokenStorageService = inject(TokenStorageService); private readonly userService = inject(UserService); public constructor() { @@ -129,11 +123,6 @@ export class GfAdminJobsComponent implements OnInit { this.defaultDateTimeFormat = getDateWithTimeFormatString( this.user.settings.locale ); - - this.hasPermissionToAccessBullBoard = hasPermission( - this.user.permissions, - permissions.accessAdminControlBullBoard - ); } }); @@ -193,18 +182,6 @@ export class GfAdminJobsComponent implements OnInit { }); } - protected onOpenBullBoard() { - const token = this.tokenStorageService.getToken(); - - document.cookie = [ - `${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`, - 'path=/', - 'SameSite=Strict' - ].join('; '); - - window.open(BULL_BOARD_ROUTE, '_blank'); - } - protected onViewData(aData: AdminJobs['jobs'][0]['data']) { this.notificationService.alert({ title: JSON.stringify(aData, null, ' ') diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.html b/apps/client/src/app/components/admin-jobs/admin-jobs.html index d57704b86..e615db31b 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.html +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -1,15 +1,6 @@
- @if (hasPermissionToAccessBullBoard) { -
- -
- } -
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.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 560a00164..ce997cf27 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -8,6 +8,7 @@ import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; import { canDeleteAssetProfile, DATE_FORMAT, + getCountryName, getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; @@ -224,6 +225,7 @@ export class GfAssetProfileDialogComponent implements OnInit { value: 'max' } ]; + protected readonly getCountryName = getCountryName; protected historicalDataItems: LineChartItem[]; protected isBenchmark = false; protected isDataGatheringEnabled: boolean; @@ -246,6 +248,8 @@ export class GfAssetProfileDialogComponent implements OnInit { [name: string]: { name: string; value: number }; }; + protected readonly translate = translate; + protected user: User; private benchmarks: Partial[]; @@ -367,9 +371,9 @@ export class GfAssetProfileDialogComponent implements OnInit { this.assetProfile?.countries && this.assetProfile.countries.length > 0 ) { - for (const { code, name, weight } of this.assetProfile.countries) { + for (const { code, weight } of this.assetProfile.countries) { this.countries[code] = { - name, + name: getCountryName({ code, locale: this.data.locale }), value: weight }; } @@ -381,7 +385,7 @@ export class GfAssetProfileDialogComponent implements OnInit { ) { for (const { name, weight } of this.assetProfile.sectors) { this.sectors[name] = { - name, + name: translate(name), value: weight }; } 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..d9a5354e5 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 + }}
@@ -269,7 +269,12 @@ i18n size="medium" [locale]="data.locale" - [value]="assetProfile?.countries[0].name" + [value]=" + getCountryName({ + code: assetProfile?.countries[0].code, + locale: data.locale + }) + " >Country
diff --git a/apps/client/src/app/components/admin-users/admin-users.component.ts b/apps/client/src/app/components/admin-users/admin-users.component.ts index 93899c9ee..f477776a5 100644 --- a/apps/client/src/app/components/admin-users/admin-users.component.ts +++ b/apps/client/src/app/components/admin-users/admin-users.component.ts @@ -59,8 +59,10 @@ import { personOutline, trashOutline } from 'ionicons/icons'; +import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { interval } from 'rxjs'; import { switchMap, tap } from 'rxjs/operators'; @Component({ @@ -184,6 +186,15 @@ export class GfAdminUsersComponent implements OnInit { public ngOnInit() { this.fetchUsers(); + + interval(ms('30 seconds')) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + 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/benchmark-comparator/benchmark-comparator.component.html b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html index 4d74c2559..328cccba1 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html @@ -53,6 +53,6 @@
diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index d2dc9e1bb..8d13fb91d 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -17,7 +17,6 @@ import { ColorScheme } from '@ghostfolio/common/types'; import { registerChartConfiguration } from '@ghostfolio/ui/chart'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -53,7 +52,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, FormsModule, GfPremiumIndicatorComponent, IonIcon, diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 8c42e37ea..416e9106d 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -2,11 +2,14 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_PAGE_SIZE, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, - NUMERICAL_PRECISION_THRESHOLD_5_FIGURES, - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_5_FIGURES } from '@ghostfolio/common/config'; import { CreateOrderDto } from '@ghostfolio/common/dtos'; -import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + downloadAsFile, + getCountryName +} from '@ghostfolio/common/helper'; import { Activity, DataProviderInfo, @@ -121,6 +124,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { public dividendInBaseCurrencyPrecision = 2; public dividendYieldPercentWithCurrencyEffect: number; public feeInBaseCurrency: number; + public getCountryName = getCountryName; public hasPermissionToCreateOwnTag: boolean; public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; public historicalDataItems: LineChartItem[]; @@ -157,6 +161,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { public SymbolProfile: EnhancedSymbolProfile; public tags: Tag[]; public tagsAvailable: Tag[]; + public translate = translate; public user: User; public value: number; @@ -276,7 +281,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.averagePrice = averagePrice; if ( - this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && + this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES && this.data.deviceType === 'mobile' ) { this.averagePricePrecision = 0; @@ -291,7 +296,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.dividendInBaseCurrency >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.dividendInBaseCurrencyPrecision = 0; } @@ -329,7 +334,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && this.investmentInBaseCurrencyWithCurrencyEffect >= - NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0; } @@ -339,7 +344,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && - this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.marketPriceMaxPrecision = 0; } @@ -348,14 +353,14 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && - this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.marketPriceMinPrecision = 0; } if ( this.data.deviceType === 'mobile' && - this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.marketPricePrecision = 0; } @@ -364,7 +369,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if ( this.data.deviceType === 'mobile' && - this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES + this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES ) { this.netPerformancePrecision = 0; } @@ -433,7 +438,10 @@ export class GfHoldingDetailDialogComponent implements OnInit { if (SymbolProfile?.countries?.length > 0) { for (const country of SymbolProfile.countries) { this.countries[country.code] = { - name: country.name, + name: getCountryName({ + code: country.code, + locale: this.data.locale + }), value: country.weight }; } @@ -442,7 +450,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { if (SymbolProfile?.sectors?.length > 0) { for (const sector of SymbolProfile.sectors) { this.sectors[sector.name] = { - name: sector.name, + name: translate(sector.name), value: sector.weight }; } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 4b04a0986..609eec3c0 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -262,7 +262,7 @@ i18n size="medium" [locale]="data.locale" - [value]="SymbolProfile.sectors[0].name" + [value]="translate(SymbolProfile.sectors[0].name)" >Sector
@@ -272,7 +272,12 @@ i18n size="medium" [locale]="data.locale" - [value]="SymbolProfile.countries[0].name" + [value]=" + getCountryName({ + code: SymbolProfile.countries[0].code, + locale: data.locale + }) + " >Country
diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.html b/apps/client/src/app/components/investment-chart/investment-chart.component.html index 6f7b083e5..864050ea8 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.html +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.html @@ -10,5 +10,5 @@ diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 691133009..e55aebdda 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -16,7 +16,6 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in import { ColorScheme, GroupBy } from '@ghostfolio/common/types'; import { registerChartConfiguration } from '@ghostfolio/ui/chart'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -49,7 +48,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, NgxSkeletonLoaderModule], + imports: [NgxSkeletonLoaderModule], selector: 'gf-investment-chart', styleUrls: ['./investment-chart.component.scss'], templateUrl: './investment-chart.component.html' diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts index 5c2f3be79..74cc80f26 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts @@ -1,8 +1,7 @@ -import { XRayRulesSettings } from '@ghostfolio/common/interfaces'; import { GfValueComponent } from '@ghostfolio/ui/value'; -import { Component, Inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, @@ -14,22 +13,37 @@ import { MatSliderModule } from '@angular/material/slider'; import { RuleSettingsDialogParams } from './interfaces/interfaces'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - FormsModule, GfValueComponent, MatButtonModule, MatDialogModule, - MatSliderModule + MatSliderModule, + ReactiveFormsModule ], selector: 'gf-rule-settings-dialog', styleUrls: ['./rule-settings-dialog.scss'], templateUrl: './rule-settings-dialog.html' }) export class GfRuleSettingsDialogComponent { - public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; + public settingsForm: FormGroup; public constructor( @Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams, - public dialogRef: MatDialogRef - ) {} + public dialogRef: MatDialogRef, + private formBuilder: FormBuilder + ) { + this.settingsForm = this.formBuilder.group({ + thresholdMax: [this.data.settings.thresholdMax], + thresholdMin: [this.data.settings.thresholdMin] + }); + } + + public onSubmit() { + this.dialogRef.close({ + ...this.data.settings, + thresholdMax: this.settingsForm.get('thresholdMax')?.value, + thresholdMin: this.settingsForm.get('thresholdMin')?.value + }); + } } diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html index c88a9dc9d..d81a3c1f3 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html @@ -1,132 +1,142 @@
{{ data.categoryName }} › {{ data.rule.name }}
-
- @if ( - data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax - ) { -
-
- Threshold range: - - - - -
-
- - - - - - + +
+ @if ( + data.rule.configuration.thresholdMin && + data.rule.configuration.thresholdMax + ) { +
+
+ Threshold range: + + - + +
+
+ + + + + + +
-
- } @else { -
-
- Threshold Min: - -
-
- - - - - + } @else { +
+
+ Threshold Min: + +
+
+ + + + + +
-
-
-
- Threshold Max: - -
-
- - - - - +
+
+ Threshold Max: + +
+
+ + + + + +
-
- } -
+ } +
-
- - -
+
+ + +
+ diff --git a/apps/client/src/app/components/rules/rules.component.html b/apps/client/src/app/components/rules/rules.component.html index 0c3153c52..97b41e61b 100644 --- a/apps/client/src/app/components/rules/rules.component.html +++ b/apps/client/src/app/components/rules/rules.component.html @@ -3,9 +3,7 @@
@if (isLoading) { - } - - @if (rules !== null && rules !== undefined) { + } @else if (rules) { @for (rule of rules; track rule.key) { +
+ } @else { + + + + } } } + + + +
+
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 0b377e57a..3415a7cb0 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.scss +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.scss @@ -37,7 +37,12 @@ @include mat.tabs-overrides( ( - container-height: 2rem + active-focus-label-text-color: rgba(var(--palette-foreground-base), 1), + active-hover-label-text-color: rgba(var(--palette-foreground-base), 1), + active-label-text-color: rgba(var(--palette-foreground-base), 1), + active-ripple-color: rgba(var(--palette-foreground-base), 1), + container-height: 2rem, + inactive-ripple-color: rgba(var(--palette-foreground-base), 1) ) ); @@ -51,7 +56,15 @@ flex-direction: column; .mat-mdc-tab-link { + border-radius: 0.25rem; + font-weight: 400; justify-content: flex-start; + margin: 0 0.5rem 0.1rem 0.5rem; + + &.mdc-tab--active { + background-color: rgba(var(--palette-foreground-base), 0.05); + font-weight: 500; + } } } } @@ -61,8 +74,32 @@ :host-context(.theme-dark) { @media (min-width: 576px) { - .mat-mdc-tab-header { - background-color: rgba(var(--palette-foreground-base-dark), 0.02); + @include mat.tabs-overrides( + ( + active-focus-label-text-color: rgba( + var(--palette-foreground-base-dark), + 1 + ), + active-hover-label-text-color: rgba( + var(--palette-foreground-base-dark), + 1 + ), + active-label-text-color: rgba(var(--palette-foreground-base-dark), 1), + active-ripple-color: rgba(var(--palette-foreground-base-dark), 1), + inactive-ripple-color: rgba(var(--palette-foreground-base-dark), 1) + ) + ); + + ::ng-deep { + .mat-mdc-tab-header { + background-color: rgba(var(--palette-foreground-base-dark), 0.02); + + .mat-mdc-tab-link { + &.mdc-tab--active { + background-color: rgba(var(--palette-foreground-base-dark), 0.05); + } + } + } } } } diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.ts b/libs/ui/src/lib/page-tabs/page-tabs.component.ts index 61c2caf05..a6ab9cb18 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.ts +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -13,7 +14,7 @@ import { TabConfiguration } from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [IonIcon, MatTabsModule, RouterModule], + imports: [IonIcon, MatTabsModule, NgTemplateOutlet, RouterModule], selector: 'gf-page-tabs', styleUrls: ['./page-tabs.component.scss'], templateUrl: './page-tabs.component.html' diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html index c7de5ef4d..75e545d30 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html @@ -7,7 +7,4 @@ }" /> } - + diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index cfe723c19..60e983684 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts @@ -4,7 +4,6 @@ import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper'; import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { ColorScheme } from '@ghostfolio/common/types'; -import { CommonModule } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, @@ -34,8 +33,6 @@ import Color from 'color'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import OpenColor from 'open-color'; -import { translate } from '../i18n'; - export interface PortfolioProportionChartClickEvent { dataSource?: DataSource; symbol: string; @@ -58,7 +55,7 @@ const { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, NgxSkeletonLoaderModule], + imports: [NgxSkeletonLoaderModule], selector: 'gf-portfolio-proportion-chart', styleUrls: ['./portfolio-proportion-chart.component.scss'], templateUrl: './portfolio-proportion-chart.component.html' @@ -390,7 +387,7 @@ export class GfPortfolioProportionChartComponent return value > 0 ? isUUID(symbol) - ? (translate(this.data[symbol]?.name) ?? symbol) + ? (this.data[symbol]?.name ?? symbol) : symbol : ''; }, @@ -453,7 +450,7 @@ export class GfPortfolioProportionChartComponent symbol = $localize`No data available`; } - const name = translate(this.data[symbol]?.name); + const name = this.data[symbol]?.name; let sum = 0; diff --git a/libs/ui/src/lib/premium-indicator/premium-indicator.component.html b/libs/ui/src/lib/premium-indicator/premium-indicator.component.html index 3141414e7..71baae6cb 100644 --- a/libs/ui/src/lib/premium-indicator/premium-indicator.component.html +++ b/libs/ui/src/lib/premium-indicator/premium-indicator.component.html @@ -1,7 +1,7 @@ diff --git a/libs/ui/src/lib/premium-indicator/premium-indicator.component.ts b/libs/ui/src/lib/premium-indicator/premium-indicator.component.ts index b3ccfd88f..0c3cd6ad7 100644 --- a/libs/ui/src/lib/premium-indicator/premium-indicator.component.ts +++ b/libs/ui/src/lib/premium-indicator/premium-indicator.component.ts @@ -1,6 +1,5 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -14,7 +13,7 @@ import { diamondOutline } from 'ionicons/icons'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, IonIcon, RouterModule], + imports: [IonIcon, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-premium-indicator', styleUrls: ['./premium-indicator.component.scss'], diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.html b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html index c7de5ef4d..75e545d30 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.html +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html @@ -7,7 +7,4 @@ }" /> } - + diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 910914230..36ea0023a 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -10,7 +10,6 @@ import { } from '@ghostfolio/common/interfaces'; import { ColorScheme, DateRange } from '@ghostfolio/common/types'; -import { CommonModule } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, @@ -45,7 +44,7 @@ const { gray, green, red } = OpenColor; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, NgxSkeletonLoaderModule], + imports: [NgxSkeletonLoaderModule], selector: 'gf-treemap-chart', styleUrls: ['./treemap-chart.component.scss'], templateUrl: './treemap-chart.component.html' diff --git a/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts b/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts index f86d4d010..0d5eb7387 100644 --- a/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts +++ b/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts @@ -1,4 +1,8 @@ -import { getLocale, getNumberFormatGroup } from '@ghostfolio/common/helper'; +import { + getCountryName, + getLocale, + getNumberFormatGroup +} from '@ghostfolio/common/helper'; import { ChangeDetectionStrategy, @@ -25,7 +29,7 @@ export class GfWorldMapChartComponent implements OnChanges, OnDestroy { @Input() locale = getLocale(); public isLoading = true; - public svgMapElement; + public svgMapElement: any; public constructor(private changeDetectorRef: ChangeDetectorRef) {} @@ -88,6 +92,14 @@ export class GfWorldMapChartComponent implements OnChanges, OnDestroy { targetElementID: 'svgMap' }); + this.svgMapElement.options.countryNames = Object.keys( + this.svgMapElement.countries + ).reduce<{ [code: string]: string }>((names, code) => { + names[code] = getCountryName({ code, locale: this.locale }); + + return names; + }, {}); + setTimeout(() => { this.isLoading = false; diff --git a/package-lock.json b/package-lock.json index e7b5a2bca..9a1d96138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.7.0", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.7.0", + "version": "3.11.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -21,9 +21,9 @@ "@angular/platform-browser-dynamic": "21.2.7", "@angular/router": "21.2.7", "@angular/service-worker": "21.2.7", - "@bull-board/api": "7.1.5", - "@bull-board/express": "7.1.5", - "@bull-board/nestjs": "7.1.5", + "@bull-board/api": "7.2.1", + "@bull-board/express": "7.2.1", + "@bull-board/nestjs": "7.2.1", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.6", @@ -63,7 +63,7 @@ "countries-and-timezones": "3.9.0", "countries-list": "3.3.0", "countup.js": "2.10.0", - "date-fns": "4.1.0", + "date-fns": "4.4.0", "dotenv": "17.2.3", "dotenv-expand": "12.0.3", "envalid": "8.1.1", @@ -3523,33 +3523,33 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bull-board/api": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz", - "integrity": "sha512-EW0sbTtGIysu9vipdVpPQeToPqOpPgVZTt+pn1Ut3gbSS/GLWbEgIfFtMmSQDUoSL9WH00RzjgUY5K+43nWh0A==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.2.1.tgz", + "integrity": "sha512-ldRG4POJLHf6oDrbDA7AsbTKliBmV4eySlwdUAumiRDtfvtbRSdXGE4Md2uPDova1r/ck7ExEe1+pHEQAZElqw==", "license": "MIT", "dependencies": { "redis-info": "^3.1.0" }, "peerDependencies": { - "@bull-board/ui": "7.1.5" + "@bull-board/ui": "7.2.1" } }, "node_modules/@bull-board/express": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.1.5.tgz", - "integrity": "sha512-kp4SzhVjZlykryiQwcOhJjDhiLbBnZoAMoSgEstzqQ0raLw+jERRC6ryJ0MIQO+SO+Jv9EjjxrXCR8O2YSP/eg==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.2.1.tgz", + "integrity": "sha512-tBr/xV5letzKYPRGRkilTQZmfoCoy3mCuUo4M2dDoDKOhbrF360mK5v9/rIcSgYSyI9c7BgEgrve80LhmexNxQ==", "license": "MIT", "dependencies": { - "@bull-board/api": "7.1.5", - "@bull-board/ui": "7.1.5", - "ejs": "^5.0.2", + "@bull-board/api": "7.2.1", + "@bull-board/ui": "7.2.1", + "ejs": "^6.0.1", "express": "^5.2.1" } }, "node_modules/@bull-board/express/node_modules/ejs": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", - "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", + "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==", "license": "Apache-2.0", "bin": { "ejs": "bin/cli.js" @@ -3559,12 +3559,12 @@ } }, "node_modules/@bull-board/nestjs": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.1.5.tgz", - "integrity": "sha512-1y+HkjnDaZoSCXJRsiYfBNBVx+PX3I8x3Uv+SSJuSpt2vHifMRwFbChO3XDxeWXetT1eR+yqPVq6ub5eJwNOYQ==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.2.1.tgz", + "integrity": "sha512-Uq2Z3+0ORgHJSw4TDV1kBrHdksRnK8CZdda63hrStduPnvKHPBxIZUGNfBN/vL08UqizpNkjFmNyNXiHOgf0LQ==", "license": "MIT", "peerDependencies": { - "@bull-board/api": "^7.1.5", + "@bull-board/api": "^7.2.1", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", @@ -3573,12 +3573,12 @@ } }, "node_modules/@bull-board/ui": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.1.5.tgz", - "integrity": "sha512-2IkatKwNRx/1M9/lAZIptcxS1FPNq6icpp2M46Upwd4olVxs/ujF9Kvs+Ff9ExtIO/OgYfwx7mG2IprGZ+nQCg==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.2.1.tgz", + "integrity": "sha512-O4ykrXrl2UJNHnhJrCvJxrw1ar+DlUBgyZUeZ8Ci+Ne5Wbq6rBv1gfpQH54/eu3IFbLso0S/kjc6WUGb2HPqZw==", "license": "MIT", "dependencies": { - "@bull-board/api": "7.1.5" + "@bull-board/api": "7.2.1" } }, "node_modules/@cacheable/utils": { @@ -20491,9 +20491,9 @@ } }, "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 4fa3e522a..56d673d40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.7.0", + "version": "3.11.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -65,9 +65,9 @@ "@angular/platform-browser-dynamic": "21.2.7", "@angular/router": "21.2.7", "@angular/service-worker": "21.2.7", - "@bull-board/api": "7.1.5", - "@bull-board/express": "7.1.5", - "@bull-board/nestjs": "7.1.5", + "@bull-board/api": "7.2.1", + "@bull-board/express": "7.2.1", + "@bull-board/nestjs": "7.2.1", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.6", @@ -107,7 +107,7 @@ "countries-and-timezones": "3.9.0", "countries-list": "3.3.0", "countup.js": "2.10.0", - "date-fns": "4.1.0", + "date-fns": "4.4.0", "dotenv": "17.2.3", "dotenv-expand": "12.0.3", "envalid": "8.1.1",