Browse Source

Merge remote-tracking branch 'upstream/main' into feature/oidc-user-link

pull/6075/head
Germán Martín 4 weeks ago
parent
commit
b8a93a1f09
  1. 122
      CHANGELOG.md
  2. 54
      README.md
  3. 4
      apps/api/src/app/activities/activities.module.ts
  4. 57
      apps/api/src/app/activities/activities.service.ts
  5. 80
      apps/api/src/app/admin/admin.controller.ts
  6. 2
      apps/api/src/app/admin/admin.module.ts
  7. 450
      apps/api/src/app/admin/admin.service.ts
  8. 2
      apps/api/src/app/app.module.ts
  9. 4
      apps/api/src/app/auth/auth.module.ts
  10. 4
      apps/api/src/app/auth/google.strategy.ts
  11. 9
      apps/api/src/app/auth/oidc.strategy.ts
  12. 6
      apps/api/src/app/auth/web-auth.service.ts
  13. 87
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  14. 25
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  15. 482
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  16. 7
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  17. 12
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  18. 4
      apps/api/src/app/health/health.controller.ts
  19. 4
      apps/api/src/app/import/import.controller.ts
  20. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  21. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  22. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  23. 156
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  24. 117
      apps/api/src/app/portfolio/portfolio.service.ts
  25. 6
      apps/api/src/app/redis-cache/redis-cache.service.ts
  26. 14
      apps/api/src/app/subscription/subscription.controller.ts
  27. 9
      apps/api/src/app/subscription/subscription.service.ts
  28. 4
      apps/api/src/app/symbol/symbol.service.ts
  29. 20
      apps/api/src/app/user/user.service.ts
  30. 12
      apps/api/src/events/asset-profile-changed.listener.ts
  31. 7
      apps/api/src/events/portfolio-changed.listener.ts
  32. 17
      apps/api/src/helper/country.helper.ts
  33. 28
      apps/api/src/helper/sector.helper.ts
  34. 4
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  35. 1
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  36. 18
      apps/api/src/main.ts
  37. 8
      apps/api/src/middlewares/html-template.middleware.ts
  38. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  39. 1
      apps/api/src/services/configuration/configuration.service.ts
  40. 8
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  41. 42
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  42. 112
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  43. 40
      apps/api/src/services/data-provider/data-provider.service.ts
  44. 40
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  45. 49
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  46. 12
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  47. 4
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  48. 9
      apps/api/src/services/data-provider/manual/manual.service.ts
  49. 6
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  50. 23
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  51. 19
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  52. 45
      apps/api/src/services/fetch/fetch.service.ts
  53. 8
      apps/api/src/services/i18n/i18n.service.ts
  54. 1
      apps/api/src/services/interfaces/environment.interface.ts
  55. 4
      apps/api/src/services/prisma/prisma.service.ts
  56. 3
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  57. 42
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  58. 44
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  59. 3
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  60. 17
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  61. 3
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  62. 55
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  63. 87
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  64. 9
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  65. 10
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  66. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  67. 23
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  68. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  69. 26
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  70. 13
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  71. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  72. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  73. 10
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  74. 18
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  75. 5
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  76. 5
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  77. 26
      apps/client/src/app/components/admin-users/admin-users.component.ts
  78. 6
      apps/client/src/app/components/admin-users/admin-users.html
  79. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  80. 4
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  81. 12
      apps/client/src/app/components/footer/footer.component.html
  82. 231
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  83. 8
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  84. 4
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  85. 2
      apps/client/src/app/components/investment-chart/investment-chart.component.html
  86. 8
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  87. 30
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  88. 262
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  89. 4
      apps/client/src/app/components/rules/rules.component.html
  90. 4
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss
  91. 41
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  92. 100
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  93. 2
      apps/client/src/app/pages/about/overview/about-overview-page.html
  94. 63
      apps/client/src/app/pages/admin/admin-page.component.ts
  95. 2
      apps/client/src/app/pages/faq/overview/faq-overview-page.html
  96. 2
      apps/client/src/app/pages/faq/saas/saas-page.html
  97. 2
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  98. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  99. 5
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  100. 11
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

122
CHANGELOG.md

@ -5,6 +5,128 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added an icon to indicate external links in the page tabs component
- Added the Korean (`ko`) language to the footer
### Changed
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel
- Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel
- Improved the sorting to be case-insensitive in the platform management of the admin control panel
- Improved the sorting to be case-insensitive in the tag management of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3`
### Fixed
- Fixed an issue with the localization of the country names
## 3.12.0 - 2026-06-17
### Changed
- Improved the styling of the checkboxes to consistently use the primary color in their states
- Improved the account name display in the accounts table
- Improved the name display in the activities table
- Improved the last activity display in the users table of the admin control panel
- Improved the registration display in the users table of the admin control panel
- Improved the user id display in the users table of the admin control panel
- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Improved the language localization for German (`de`)
- Upgraded `svgmap` from version `2.19.3` to `2.21.0`
### Fixed
- Fixed a chart error on interaction by registering the annotation plugin early
- Fixed an issue on the allocations page where clicking an account in the _By Account_ chart did not open the detail dialog
- Restricted the maximum height of the import activities dialog
- Fixed the dark mode styling of the safe withdrawal rate selector in the _FIRE_ section (experimental)
## 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 ## 3.7.0 - 2026-06-02
### Added ### Added

54
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/<INSERT_DATA_SOURCE>/<INSERT_SYMBOL>`
#### 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 ## Community Projects
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
@ -310,7 +362,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you. Ghostfolio is **100% free** and **open source**. We support an active and healthy community and welcome contributions from everyone, including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.

4
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 { 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 { 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 { 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -23,11 +25,13 @@ import { ActivitiesService } from './activities.service';
exports: [ActivitiesService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
BenchmarkModule,
CacheModule, CacheModule,
DataGatheringQueueModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule, RedactValuesInResponseModule,
RedisCacheModule, RedisCacheModule,

57
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 { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -16,7 +18,10 @@ import {
ghostfolioPrefix, ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
canDeleteAssetProfile,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
Activity, Activity,
@ -48,10 +53,12 @@ export class ActivitiesService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -262,7 +269,26 @@ export class ActivitiesService {
activity.symbolProfileId 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); await this.symbolProfileService.deleteById(activity.symbolProfileId);
} }
@ -308,8 +334,31 @@ export class ActivitiesService {
}) })
); );
for (const { activitiesCount, id } of symbolProfiles) { const benchmarkAssetProfiles =
if (activitiesCount === 0) { 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); await this.symbolProfileService.deleteById(id);
} }
} }

80
apps/api/src/app/admin/admin.controller.ts

@ -1,10 +1,11 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -16,21 +17,19 @@ import {
UpdateAssetProfileDto, UpdateAssetProfileDto,
UpdatePropertyDto UpdatePropertyDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
canDeleteAssetProfile,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
ScraperConfiguration ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
DateRange,
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -58,13 +57,16 @@ import { AdminService } from './admin.service';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
private readonly logger = new Logger(AdminController.name);
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService, private readonly demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@Get() @Get()
@ -209,35 +211,6 @@ export class AdminController {
}); });
} }
@Get('market-data')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery
});
return this.adminService.getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test') @Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -260,7 +233,7 @@ export class AdminController {
`Could not parse the market price for ${symbol} (${dataSource})` `Could not parse the market price for ${symbol} (${dataSource})`
); );
} catch (error) { } catch (error) {
Logger.error(error, 'AdminController'); this.logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST); throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
} }
@ -288,6 +261,33 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
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 }); return this.adminService.deleteProfileData({ dataSource, symbol });
} }

2
apps/api/src/app/admin/admin.module.ts

@ -1,6 +1,5 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -21,7 +20,6 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringQueueModule, DataGatheringQueueModule,

450
apps/api/src/app/admin/admin.service.ts

@ -1,6 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -13,24 +12,15 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { import {
BadRequestException, BadRequestException,
@ -48,13 +38,11 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -188,270 +176,6 @@ export class AdminService {
}; };
} }
public async getMarketData({
filters,
presetId,
sortColumn,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const {
ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: filtersByDataSource
} = groupBy(filters, ({ type }) => {
return type;
});
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = [
{
activities: {
_count: sortDirection
}
}
];
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const assetProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
activities,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
name,
sectors,
symbol,
SymbolProfileOverrides
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
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;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
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) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = marketData.length;
}
return {
count,
marketData
};
}
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
symbol symbol
@ -516,6 +240,24 @@ export class AdminService {
throw new NotFoundException(`User with ID ${id} not found`); throw new NotFoundException(`User with ID ${id} not found`);
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const subscriptions = await this.prismaService.subscription.findMany({
orderBy: {
expiresAt: 'desc'
},
where: {
userId: id
}
});
user.subscriptions = subscriptions.map((subscription) => {
return {
...subscription,
price: subscription.price ?? 0
};
});
}
return user; return user;
} }
@ -619,6 +361,7 @@ export class AdminService {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray, countries: countries as Prisma.JsonArray,
holdings: holdings as Prisma.JsonArray,
name: name as string, name: name as string,
sectors: sectors as Prisma.JsonArray, sectors: sectors as Prisma.JsonArray,
url: url as string url: url as string
@ -628,21 +371,14 @@ export class AdminService {
comment, comment,
currency, currency,
dataSource, dataSource,
holdings,
isActive, isActive,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping, symbolMapping,
...(dataSource === 'MANUAL' ...this.symbolProfileService.getAssetProfileUpdateInput(
? { assetClass, assetSubClass, countries, name, sectors, url } { dataSource, symbol },
: { symbolProfileOverrides
SymbolProfileOverrides: { )
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
}; };
await this.symbolProfileService.updateSymbolProfile( await this.symbolProfileService.updateSymbolProfile(
@ -699,138 +435,6 @@ export class AdminService {
}); });
} }
private getExtendedPrismaClient() {
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
activities: {
where: {
user: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.activities > 0;
}
}
}
}
});
});
return this.prismaService.$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0,
watchedByCount: 0
};
}
);
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics({ private async getUsersWithAnalytics({
skip, skip,
take, take,
@ -882,7 +486,7 @@ export class AdminService {
activityCount: true, activityCount: true,
country: true, country: true,
dataProviderGhostfolioDailyRequests: true, dataProviderGhostfolioDailyRequests: true,
updatedAt: true lastRequestAt: true
} }
}, },
createdAt: true, createdAt: true,
@ -928,7 +532,7 @@ export class AdminService {
activityCount: _count.activities || 0, activityCount: _count.activities || 0,
country: analytics?.country, country: analytics?.country,
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: analytics?.updatedAt lastActivity: analytics?.lastRequestAt
}; };
} }
); );

2
apps/api/src/app/app.module.ts

@ -38,6 +38,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.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 { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
@ -69,6 +70,7 @@ import { UserModule } from './user/user.module';
ActivitiesModule, ActivitiesModule,
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetProfilesModule,
AssetModule, AssetModule,
AssetsModule, AssetsModule,
AuthDeviceModule, AuthDeviceModule,

4
apps/api/src/app/auth/auth.module.ts

@ -60,6 +60,8 @@ import { OidcStrategy } from './oidc.strategy';
jwtService: JwtService, jwtService: JwtService,
stateStore: OidcStateStore stateStore: OidcStateStore
) => { ) => {
const logger = new Logger('OidcStrategy');
const isOidcEnabled = configurationService.get( const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC' 'ENABLE_FEATURE_AUTH_OIDC'
); );
@ -108,7 +110,7 @@ import { OidcStrategy } from './oidc.strategy';
tokenURL = manualTokenUrl || config.token_endpoint; tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); logger.error(error);
throw new Error('Failed to fetch OIDC configuration from issuer'); throw new Error('Failed to fetch OIDC configuration from issuer');
} }
} }

4
apps/api/src/app/auth/google.strategy.ts

@ -10,6 +10,8 @@ import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
private readonly logger = new Logger(GoogleStrategy.name);
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
configurationService: ConfigurationService configurationService: ConfigurationService
@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, { jwt }); done(null, { jwt });
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleStrategy'); this.logger.error(error);
done(error, false); done(error, false);
} }
} }

9
apps/api/src/app/auth/oidc.strategy.ts

@ -17,6 +17,8 @@ import { OidcStateStore } from './oidc-state.store';
@Injectable() @Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private readonly logger = new Logger(OidcStrategy.name);
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
@ -49,9 +51,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
context?.claims?.sub; context?.claims?.sub;
if (!thirdPartyId) { if (!thirdPartyId) {
Logger.error( this.logger.error(
`Missing subject identifier in OIDC response from ${issuer}`, `Missing subject identifier in OIDC response from ${issuer}`
'OidcStrategy'
); );
throw new Error('Missing subject identifier in OIDC response'); throw new Error('Missing subject identifier in OIDC response');
@ -82,7 +83,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
return { jwt, thirdPartyId } as OidcValidationResult; return { jwt, thirdPartyId } as OidcValidationResult;
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); this.logger.error(error);
throw error; throw error;
} }
} }

6
apps/api/src/app/auth/web-auth.service.ts

@ -33,6 +33,8 @@ import ms from 'ms';
@Injectable() @Injectable()
export class WebAuthService { export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService, private readonly deviceService: AuthDeviceService,
@ -103,7 +105,7 @@ export class WebAuthService {
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
@ -210,7 +212,7 @@ export class WebAuthService {
verification = await verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }

87
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -0,0 +1,87 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import {
AssetProfilesResponse,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Param,
Patch,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, Prisma } 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 apiService: ApiService,
private readonly assetProfilesService: AssetProfilesService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAssetProfiles(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AssetProfilesResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery
});
return this.assetProfilesService.getAssetProfiles({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@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<EnhancedSymbolProfile> {
if (!this.request.user.settings.settings.isExperimentalFeatures) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.assetProfilesService.updateAssetProfileData(
{ dataSource, symbol },
assetProfileData
);
}
}

25
apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts

@ -0,0 +1,25 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
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: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ExchangeRateDataModule,
PrismaModule,
SymbolProfileModule
],
providers: [AssetProfilesService]
})
export class AssetProfilesModule {}

482
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -0,0 +1,482 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import {
AssetProfileItem,
AssetProfileIdentifier,
AssetProfilesResponse,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, NotFoundException } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash';
@Injectable()
export class AssetProfilesService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getAssetProfiles({
filters = [],
presetId,
sortColumn,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AssetProfilesResponse> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getAssetProfilesForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const {
ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: filtersByDataSource
} = groupBy(filters, ({ type }) => {
return type;
});
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = [
{
activities: {
_count: sortDirection
}
}
];
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isin: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const symbolProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: symbolProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: symbolProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let assetProfiles: AssetProfileItem[] = await Promise.all(
symbolProfiles.map(async (assetProfile) => {
const {
_count,
activities,
comment,
currency,
dataSource,
id,
isin,
isActive,
isUsedByUsersWithSubscription,
symbol
} = assetProfile;
const { assetClass, assetSubClass, countries, name, sectors } =
applyAssetProfileOverrides(
assetProfile,
assetProfile.SymbolProfileOverrides
);
const countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
currency,
dataSource,
id,
isActive,
isin,
lastMarketPrice,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
})
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
assetProfiles = assetProfiles.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
assetProfiles = assetProfiles.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = assetProfiles.length;
}
return {
assetProfiles,
count
};
}
public async updateAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
assetProfileData: UpdateAssetProfileDataDto
): Promise<EnhancedSymbolProfile> {
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;
}
private async getAssetProfilesForCurrencies(): Promise<AssetProfilesResponse> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const assetProfilePromises: Promise<AssetProfileItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0,
watchedByCount: 0
};
}
);
const assetProfiles = await Promise.all(assetProfilePromises);
return { assetProfiles, count: assetProfiles.length };
}
private getExtendedPrismaClient() {
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
activities: {
where: {
user: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.activities > 0;
}
}
}
}
});
});
return this.prismaService.$extends(symbolProfileExtension);
}
}

7
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -17,6 +17,8 @@ import { isNumber } from 'lodash';
@Injectable() @Injectable()
export class BenchmarksService { export class BenchmarksService {
private readonly logger = new Logger(BenchmarksService.name);
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -96,12 +98,11 @@ export class BenchmarksService {
})?.marketPrice; })?.marketPrice;
if (!marketPriceAtStartDate) { if (!marketPriceAtStartDate) {
Logger.error( this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( `No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate, startDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
'BenchmarkService'
); );
return { marketData }; return { marketData };

12
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -34,6 +34,8 @@ import { Big } from 'big.js';
@Injectable() @Injectable()
export class GhostfolioService { export class GhostfolioService {
private readonly logger = new Logger(GhostfolioService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -99,7 +101,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -141,7 +143,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -183,7 +185,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -271,7 +273,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -348,7 +350,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }

4
apps/api/src/app/health/health.controller.ts

@ -24,6 +24,8 @@ import { HealthService } from './health.service';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
private readonly logger = new Logger(HealthController.name);
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly healthService: HealthService private readonly healthService: HealthService
@ -61,7 +63,7 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.OK) }); .json({ status: getReasonPhrase(StatusCodes.OK) });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'HealthController'); this.logger.error(error);
} }
return response return response

4
apps/api/src/app/import/import.controller.ts

@ -31,6 +31,8 @@ import { ImportService } from './import.service';
@Controller('import') @Controller('import')
export class ImportController { export class ImportController {
private readonly logger = new Logger(ImportController.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly importService: ImportService, private readonly importService: ImportService,
@ -81,7 +83,7 @@ export class ImportController {
return { activities }; return { activities };
} catch (error) { } catch (error) {
Logger.error(error, ImportController); this.logger.error(error);
throw new HttpException( throw new HttpException(
{ {

7
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -62,6 +62,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[]; protected activities: PortfolioOrder[];
@ -1119,12 +1121,11 @@ export abstract class PortfolioCalculator {
if (cachedPortfolioSnapshot) { if (cachedPortfolioSnapshot) {
this.snapshot = cachedPortfolioSnapshot; this.snapshot = cachedPortfolioSnapshot;
Logger.debug( this.logger.debug(
`Fetched portfolio snapshot from cache in ${( `Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'PortfolioCalculator'
); );
if (isCachedPortfolioSnapshotExpired) { if (isCachedPortfolioSnapshotExpired) {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -116,10 +116,12 @@ describe('PortfolioCalculator', () => {
accountBalanceService, accountBalanceService,
accountService, accountService,
null, null,
null,
dataProviderService, dataProviderService,
null, null,
exchangeRateDataService, exchangeRateDataService,
null, null,
null,
null null
); );

6
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 { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addMilliseconds, addMilliseconds,
@ -96,9 +95,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
currentPosition.timeWeightedInvestmentWithCurrencyEffect currentPosition.timeWeightedInvestmentWithCurrencyEffect
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;

156
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { parseDate } from '@ghostfolio/common/helper';
import { Account, DataSource } from '@prisma/client'; import { Account, DataSource } from '@prisma/client';
@ -59,10 +60,12 @@ describe('PortfolioService', () => {
null, null,
accountService, accountService,
null, null,
null,
dataProviderService, dataProviderService,
null, null,
exchangeRateDataService, exchangeRateDataService,
null, null,
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<string, { valueInBaseCurrency: number }>;
};
}
).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', () => { describe('getCashSymbolProfiles', () => {
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => {
jest jest
@ -269,4 +333,96 @@ describe('PortfolioService', () => {
expect(holdings['USD'].assetProfile.symbol).toBe('USD'); expect(holdings['USD'].assetProfile.symbol).toBe('USD');
}); });
}); });
describe('getValueOfAccountsAndPlatforms', () => {
const getValueOfAccountsAndPlatforms = (args: object) => {
return (
portfolioService as unknown as {
getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{
accounts: Record<string, { valueInBaseCurrency: number }>;
platforms: Record<string, { valueInBaseCurrency: number }>;
}>;
}
).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();
});
});
}); });

117
apps/api/src/app/portfolio/portfolio.service.ts

@ -108,6 +108,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private readonly logger = new Logger(PortfolioService.name);
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
@ -619,9 +621,8 @@ export class PortfolioService {
symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })]; symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile) { if (!assetProfile) {
Logger.warn( this.logger.warn(
`Asset profile not found for ${symbol} (${dataSource})`, `Asset profile not found for ${symbol} (${dataSource})`
'PortfolioService'
); );
continue; continue;
@ -956,6 +957,18 @@ export class PortfolioService {
marketPriceMin, marketPriceMin,
SymbolProfile, SymbolProfile,
tags, tags,
assetProfile: {
assetClass: SymbolProfile.assetClass,
assetSubClass: SymbolProfile.assetSubClass,
countries: SymbolProfile.countries,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
isin: SymbolProfile.isin,
name: SymbolProfile.name,
sectors: SymbolProfile.sectors,
symbol: SymbolProfile.symbol,
userId: SymbolProfile.userId
},
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -1452,31 +1465,29 @@ export class PortfolioService {
for (const [, position] of Object.entries(holdings)) { for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency; const value = position.valueInBaseCurrency;
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { if (position.assetProfile.countries.length > 0) {
if (position.assetProfile.countries.length > 0) { markets.developedMarkets.valueInBaseCurrency +=
markets.developedMarkets.valueInBaseCurrency += position.markets.developedMarkets * value;
position.markets.developedMarkets * value; markets.emergingMarkets.valueInBaseCurrency +=
markets.emergingMarkets.valueInBaseCurrency += position.markets.emergingMarkets * value;
position.markets.emergingMarkets * value; markets.otherMarkets.valueInBaseCurrency +=
markets.otherMarkets.valueInBaseCurrency += position.markets.otherMarkets * value;
position.markets.otherMarkets * value;
marketsAdvanced.asiaPacific.valueInBaseCurrency +=
marketsAdvanced.asiaPacific.valueInBaseCurrency += position.marketsAdvanced.asiaPacific * value;
position.marketsAdvanced.asiaPacific * value; marketsAdvanced.emergingMarkets.valueInBaseCurrency +=
marketsAdvanced.emergingMarkets.valueInBaseCurrency += position.marketsAdvanced.emergingMarkets * value;
position.marketsAdvanced.emergingMarkets * value; marketsAdvanced.europe.valueInBaseCurrency +=
marketsAdvanced.europe.valueInBaseCurrency += position.marketsAdvanced.europe * value;
position.marketsAdvanced.europe * value; marketsAdvanced.japan.valueInBaseCurrency +=
marketsAdvanced.japan.valueInBaseCurrency += position.marketsAdvanced.japan * value;
position.marketsAdvanced.japan * value; marketsAdvanced.northAmerica.valueInBaseCurrency +=
marketsAdvanced.northAmerica.valueInBaseCurrency += position.marketsAdvanced.northAmerica * value;
position.marketsAdvanced.northAmerica * value; marketsAdvanced.otherMarkets.valueInBaseCurrency +=
marketsAdvanced.otherMarkets.valueInBaseCurrency += position.marketsAdvanced.otherMarkets * value;
position.marketsAdvanced.otherMarkets * value; } else {
} else { markets[UNKNOWN_KEY].valueInBaseCurrency += value;
markets[UNKNOWN_KEY].valueInBaseCurrency += value; marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
}
} }
} }
@ -2162,40 +2173,44 @@ export class PortfolioService {
return withExcludedAccounts || account.isExcluded === false; 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 }) => { const ordersByAccount = activities.filter(({ accountId }) => {
return accountId === account.id; return account ? accountId === account.id : !accountId;
}); });
accounts[account.id] = { if (account) {
balance: account.balance, accounts[account.id] = {
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] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
name: account.platform?.name, name: account.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency 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 { for (const {

6
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -10,6 +10,8 @@ import { createHash, randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
private readonly logger = new Logger(RedisCacheService.name);
private client: Keyv; private client: Keyv;
public constructor( public constructor(
@ -27,7 +29,7 @@ export class RedisCacheService {
}; };
this.client.on('error', (error) => { this.client.on('error', (error) => {
Logger.error(error, 'RedisCacheService'); this.logger.error(error);
}); });
} }
@ -101,7 +103,7 @@ export class RedisCacheService {
return true; return true;
} catch (error) { } catch (error) {
Logger.error(error?.message, 'RedisCacheService'); this.logger.error(error?.message);
return false; return false;
} finally { } finally {

14
apps/api/src/app/subscription/subscription.controller.ts

@ -33,6 +33,8 @@ import { SubscriptionService } from './subscription.service';
@Controller('subscription') @Controller('subscription')
export class SubscriptionController { export class SubscriptionController {
private readonly logger = new Logger(SubscriptionController.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -80,9 +82,8 @@ export class SubscriptionController {
value: JSON.stringify(coupons) value: JSON.stringify(coupons)
}); });
Logger.log( this.logger.log(
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`, `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`
'SubscriptionController'
); );
return { return {
@ -101,9 +102,8 @@ export class SubscriptionController {
); );
if (userId) { if (userId) {
Logger.log( this.logger.log(
`Subscription for user '${userId}' has been created via Stripe`, `Subscription for user '${userId}' has been created via Stripe`
'SubscriptionController'
); );
} }
@ -126,7 +126,7 @@ export class SubscriptionController {
user: this.request.user user: this.request.user
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'SubscriptionController'); this.logger.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

9
apps/api/src/app/subscription/subscription.service.ts

@ -24,6 +24,8 @@ import Stripe from 'stripe';
@Injectable() @Injectable()
export class SubscriptionService { export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
private stripe: Stripe; private stripe: Stripe;
public constructor( public constructor(
@ -166,9 +168,8 @@ export class SubscriptionService {
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002' error.code === 'P2002'
) { ) {
Logger.log( this.logger.log(
`Stripe Checkout Session '${session.id}' has already been redeemed`, `Stripe Checkout Session '${session.id}' has already been redeemed`
'SubscriptionService'
); );
} else { } else {
throw error; throw error;
@ -177,7 +178,7 @@ export class SubscriptionService {
return session.client_reference_id; return session.client_reference_id;
} catch (error) { } catch (error) {
Logger.error(error, 'SubscriptionService'); this.logger.error(error);
} }
} }

4
apps/api/src/app/symbol/symbol.service.ts

@ -15,6 +15,8 @@ import { format, subDays } from 'date-fns';
@Injectable() @Injectable()
export class SymbolService { export class SymbolService {
private readonly logger = new Logger(SymbolService.name);
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService private readonly marketDataService: MarketDataService
@ -119,7 +121,7 @@ export class SymbolService {
results.items = items; results.items = items;
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'SymbolService'); this.logger.error(error);
throw error; throw error;
} }

20
apps/api/src/app/user/user.service.ts

@ -28,14 +28,16 @@ import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCALE,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS
locale as defaultLocale
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
User as IUser, User as IUser,
ReferralPartner,
SystemMessage, SystemMessage,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -100,7 +102,7 @@ export class UserService {
public async getUser({ public async getUser({
impersonationUserId, impersonationUserId,
locale = defaultLocale, locale = DEFAULT_LOCALE,
user user
}: { }: {
impersonationUserId: string; impersonationUserId: string;
@ -160,6 +162,17 @@ export class UserService {
(impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ??
(settings.settings as UserSettings)?.baseCurrency; (settings.settings as UserSettings)?.baseCurrency;
let referralPartners: ReferralPartner[];
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic
) {
referralPartners = await this.propertyService.getByKey<ReferralPartner[]>(
PROPERTY_REFERRAL_PARTNERS
);
}
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
const systemMessageProperty = const systemMessageProperty =
@ -187,6 +200,7 @@ export class UserService {
id, id,
permissions: userPermissions, permissions: userPermissions,
provider, provider,
referralPartners,
subscription, subscription,
systemMessage, systemMessage,
tags, tags,

12
apps/api/src/events/asset-profile-changed.listener.ts

@ -15,6 +15,8 @@ import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable() @Injectable()
export class AssetProfileChangedListener { export class AssetProfileChangedListener {
private readonly logger = new Logger(AssetProfileChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>(); private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -67,10 +69,7 @@ export class AssetProfileChangedListener {
dataSource: DataSource; dataSource: DataSource;
symbol: string; symbol: string;
}) { }) {
Logger.log( this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`);
`Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener'
);
if ( if (
this.configurationService.get( this.configurationService.get(
@ -84,10 +83,7 @@ export class AssetProfileChangedListener {
const existingCurrencies = this.exchangeRateDataService.getCurrencies(); const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(currency)) { if (!existingCurrencies.includes(currency)) {
Logger.log( this.logger.log(`New currency ${currency} has been detected`);
`New currency ${currency} has been detected`,
'AssetProfileChangedListener'
);
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
} }

7
apps/api/src/events/portfolio-changed.listener.ts

@ -8,6 +8,8 @@ import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable() @Injectable()
export class PortfolioChangedListener { export class PortfolioChangedListener {
private readonly logger = new Logger(PortfolioChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>(); private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -35,10 +37,7 @@ export class PortfolioChangedListener {
} }
private async processPortfolioChanged({ userId }: { userId: string }) { private async processPortfolioChanged({ userId }: { userId: string }) {
Logger.log( this.logger.log(`Portfolio of user '${userId}' has changed`);
`Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener'
);
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
} }

17
apps/api/src/helper/country.helper.ts

@ -0,0 +1,17 @@
import { countries } from 'countries-list';
export function getCountryCodeByName({
aliases = {},
name
}: {
aliases?: Record<string, string>;
name: string;
}): string {
for (const [code, country] of Object.entries(countries)) {
if (country.name === name || country.name === aliases[name]) {
return code;
}
}
return undefined;
}

28
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<string, SectorName>;
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';
}

4
apps/api/src/interceptors/performance-logging/performance-logging.service.ts

@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
@Injectable() @Injectable()
export class PerformanceLoggingService { export class PerformanceLoggingService {
private readonly logger = new Logger(PerformanceLoggingService.name);
public logPerformance({ public logPerformance({
className, className,
methodName, methodName,
@ -13,7 +15,7 @@ export class PerformanceLoggingService {
}) { }) {
const endTime = performance.now(); const endTime = performance.now();
Logger.debug( this.logger.debug(
`Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, `Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`,
className className
); );

1
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -64,6 +64,7 @@ export class TransformDataSourceInResponseInterceptor<
paths: [ paths: [
'activities[*].dataSource', 'activities[*].dataSource',
'activities[*].SymbolProfile.dataSource', 'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource',
'benchmarks[*].dataSource', 'benchmarks[*].dataSource',
'errors[*].dataSource', 'errors[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource', 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',

18
apps/api/src/main.ts

@ -23,6 +23,8 @@ import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
const logger = new Logger('Bootstrap');
async function bootstrap() { async function bootstrap() {
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests // Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests
setGlobalDispatcher(new EnvHttpProxyAgent()); setGlobalDispatcher(new EnvHttpProxyAgent());
@ -114,20 +116,20 @@ async function bootstrap() {
address = `${host}:${addressObject.port}`; address = `${host}:${addressObject.port}`;
} }
Logger.log(`Listening at http://${address}`); logger.log(`Listening at http://${address}`);
Logger.log(''); logger.log('');
}); });
} }
function logLogo() { function logLogo() {
Logger.log(' ________ __ ____ ___'); logger.log(' ________ __ ____ ___');
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
Logger.log( logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}` `\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
); );
Logger.log(''); logger.log('');
} }
bootstrap(); bootstrap();

8
apps/api/src/middlewares/html-template.middleware.ts

@ -92,6 +92,8 @@ const locales = {
@Injectable() @Injectable()
export class HtmlTemplateMiddleware implements NestMiddleware { export class HtmlTemplateMiddleware implements NestMiddleware {
private readonly logger = new Logger(HtmlTemplateMiddleware.name);
private indexHtmlMap: { [languageCode: string]: string } = {}; private indexHtmlMap: { [languageCode: string]: string } = {};
public constructor(private readonly i18nService: I18nService) { public constructor(private readonly i18nService: I18nService) {
@ -107,11 +109,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
{} {}
); );
} catch (error) { } catch (error) {
Logger.error( this.logger.error('Failed to initialize index HTML map', error);
'Failed to initialize index HTML map',
error,
'HTMLTemplateMiddleware'
);
} }
} }

6
apps/api/src/services/benchmark/benchmark.service.ts

@ -28,6 +28,8 @@ import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
private readonly logger = new Logger(BenchmarkService.name);
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor( public constructor(
@ -87,7 +89,7 @@ export class BenchmarkService {
const { benchmarks, expiration }: BenchmarkValue = const { benchmarks, expiration }: BenchmarkValue =
JSON.parse(cachedBenchmarkValue); JSON.parse(cachedBenchmarkValue);
Logger.debug('Fetched benchmarks from cache', 'BenchmarkService'); this.logger.debug('Fetched benchmarks from cache');
if (isAfter(new Date(), new Date(expiration))) { if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({ this.calculateAndCacheBenchmarks({
@ -227,7 +229,7 @@ export class BenchmarkService {
private async calculateAndCacheBenchmarks({ private async calculateAndCacheBenchmarks({
enableSharing = false enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> { }): Promise<BenchmarkResponse['benchmarks']> {
Logger.debug('Calculate benchmarks', 'BenchmarkService'); this.logger.debug('Calculate benchmarks');
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing enableSharing

1
apps/api/src/services/configuration/configuration.service.ts

@ -30,7 +30,6 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: 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_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),

8
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -29,6 +29,8 @@ import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface, OnModuleInit { export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private readonly logger = new Logger(CoinGeckoService.name);
private apiUrl: string; private apiUrl: string;
private headers: HeadersInit = {}; private headers: HeadersInit = {};
@ -88,7 +90,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return response; return response;
@ -214,7 +216,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return response; return response;
@ -262,7 +264,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return { items }; return { items };

42
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { Holding } from '@ghostfolio/common/interfaces'; import { Holding } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { SectorName } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { countries } from 'countries-list';
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
@ -17,13 +19,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
USA: 'United States' USA: 'United States'
}; };
private static holdingsWeightTreshold = 0.85; private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = { private static sectorsMapping: Record<string, SectorName> = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Staples': 'Consumer Defensive',
Financials: 'Financial Services',
'Health Care': 'Healthcare', 'Health Care': 'Healthcare',
'Information Technology': 'Technology' 'Information Technology': 'Technology',
Materials: 'Basic Materials'
}; };
private readonly logger = new Logger(TrackinsightDataEnhancerService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService private readonly fetchService: FetchService
@ -115,21 +121,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {} 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({ response.countries.push({
code: countryCode, code: getCountryCodeByName({
name,
aliases: TrackinsightDataEnhancerService.countriesMapping
}),
weight: value.weight weight: value.weight
}); });
} }
@ -163,7 +159,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
holdings?.sectors ?? {} holdings?.sectors ?? {}
)) { )) {
response.sectors.push({ response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, name: getSectorName({
name,
aliases: TrackinsightDataEnhancerService.sectorsMapping
}),
weight: value.weight weight: value.weight
}); });
} }
@ -209,9 +208,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return undefined; return undefined;
}) })
.catch(({ message }) => { .catch(({ message }) => {
Logger.error( this.logger.error(
`Failed to search Trackinsight symbol for ${symbol} (${message})`, `Failed to search Trackinsight symbol for ${symbol} (${message})`
'TrackinsightDataEnhancerService'
); );
return undefined; return undefined;

112
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 { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; 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 { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
REPLACE_NAME_PARTS, REPLACE_NAME_PARTS
UNKNOWN_KEY
} from '@ghostfolio/common/config'; } 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 { Injectable, Logger } from '@nestjs/common';
import { import {
@ -23,6 +24,22 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private static sectorsMapping: Record<string, SectorName> = {
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({ private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey'] suppressNotices: ['yahooSurvey']
}); });
@ -56,31 +73,21 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
* DOGEUSD -> DOGE-USD * DOGEUSD -> DOGE-USD
*/ */
public convertToYahooFinanceSymbol(aSymbol: string) { public convertToYahooFinanceSymbol(aSymbol: string) {
if ( if (isCurrencySymbol(aSymbol)) {
aSymbol.includes(DEFAULT_CURRENCY) && return `${aSymbol}=X`;
aSymbol.length > DEFAULT_CURRENCY.length } else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY)
)
) { ) {
if ( // Add a dash before the last three characters
isCurrency( // BTCUSD -> BTC-USD
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) // DOGEUSD -> DOGE-USD
) && // SOL1USD -> SOL1-USD
isCurrency(aSymbol.substring(aSymbol.length - DEFAULT_CURRENCY.length)) return aSymbol.replace(
) { new RegExp(`-?${DEFAULT_CURRENCY}$`),
return `${aSymbol}=X`; `-${DEFAULT_CURRENCY}`
} 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}`
);
}
} }
return aSymbol; return aSymbol;
@ -123,7 +130,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.url = url; response.url = url;
} }
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceDataEnhancerService'); this.logger.error(error);
} }
return response; return response;
@ -222,7 +229,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
.flatMap((sectorWeighting) => { .flatMap((sectorWeighting) => {
return Object.entries(sectorWeighting).map(([sector, weight]) => { return Object.entries(sectorWeighting).map(([sector, weight]) => {
return { return {
name: this.parseSector(sector), name: getSectorName({
aliases: YahooFinanceDataEnhancerService.sectorsMapping,
name: sector
}),
weight: weight as number weight: weight as number
}; };
}); });
@ -266,7 +276,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
`No data found, ${aSymbol} (${this.getName()}) may be delisted` `No data found, ${aSymbol} (${this.getName()}) may be delisted`
); );
} else { } else {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
} }
} }
@ -329,46 +339,4 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return { assetClass, assetSubClass }; 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;
}
} }

40
apps/api/src/services/data-provider/data-provider.service.ts

@ -41,6 +41,8 @@ import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
@Injectable() @Injectable()
export class DataProviderService implements OnModuleInit { export class DataProviderService implements OnModuleInit {
private readonly logger = new Logger(DataProviderService.name);
private dataProviderMapping: { [dataProviderName: string]: string }; private dataProviderMapping: { [dataProviderName: string]: string };
public constructor( public constructor(
@ -129,7 +131,7 @@ export class DataProviderService implements OnModuleInit {
); );
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
throw error; throw error;
} }
@ -383,15 +385,16 @@ export class DataProviderService implements OnModuleInit {
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;
r[symbol] = { if (!r[symbol]) {
...(r[symbol] || {}), r[symbol] = {};
[format(new Date(date), DATE_FORMAT)]: { marketPrice } }
};
r[symbol][format(new Date(date), DATE_FORMAT)] = { marketPrice };
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
} finally { } finally {
return response; return response;
} }
@ -503,7 +506,7 @@ export class DataProviderService implements OnModuleInit {
result[symbol] = data; result[symbol] = data;
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
throw error; throw error;
} }
@ -567,13 +570,12 @@ export class DataProviderService implements OnModuleInit {
const numberOfItemsInCache = Object.keys(response)?.length; const numberOfItemsInCache = Object.keys(response)?.length;
if (numberOfItemsInCache) { if (numberOfItemsInCache) {
Logger.debug( this.logger.debug(
`Fetched ${numberOfItemsInCache} quote${ `Fetched ${numberOfItemsInCache} quote${
numberOfItemsInCache > 1 ? 's' : '' numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3 3
)} seconds`, )} seconds`
'DataProviderService'
); );
} }
@ -684,14 +686,13 @@ export class DataProviderService implements OnModuleInit {
} }
} }
Logger.debug( this.logger.debug(
`Fetched ${symbolsChunk.length} quote${ `Fetched ${symbolsChunk.length} quote${
symbolsChunk.length > 1 ? 's' : '' symbolsChunk.length > 1 ? 's' : ''
} from ${dataSource} in ${( } from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'DataProviderService'
); );
try { try {
@ -722,15 +723,18 @@ export class DataProviderService implements OnModuleInit {
await Promise.all(promises); await Promise.all(promises);
Logger.debug('--------------------------------------------------------'); this.logger.debug(
Logger.debug( '--------------------------------------------------------'
);
this.logger.debug(
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'DataProviderService' );
this.logger.debug(
'========================================================'
); );
Logger.debug('========================================================');
return response; return response;
} }

40
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -13,7 +13,7 @@ import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
REPLACE_NAME_PARTS REPLACE_NAME_PARTS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrencySymbol } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
@ -37,6 +37,8 @@ import { isNumber } from 'lodash';
export class EodHistoricalDataService export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit implements DataProviderInterface, OnModuleInit
{ {
private readonly logger = new Logger(EodHistoricalDataService.name);
private apiKey: string; private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
@ -127,12 +129,11 @@ export class EodHistoricalDataService
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'EodHistoricalDataService'
); );
return {}; return {};
@ -172,9 +173,8 @@ export class EodHistoricalDataService
marketPrice: adjusted_close marketPrice: adjusted_close
}; };
} else { } else {
Logger.error( this.logger.error(
`Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`, `Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`
'EodHistoricalDataService'
); );
} }
@ -292,9 +292,8 @@ export class EodHistoricalDataService
dataSource: this.getName() dataSource: this.getName()
}; };
} else { } else {
Logger.error( this.logger.error(
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`
'EodHistoricalDataService'
); );
} }
} }
@ -311,7 +310,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return {}; return {};
@ -383,20 +382,11 @@ export class EodHistoricalDataService
* Currency: USDCHF -> USDCHF.FOREX * Currency: USDCHF -> USDCHF.FOREX
*/ */
private convertToEodSymbol(aSymbol: string) { private convertToEodSymbol(aSymbol: string) {
if ( if (isCurrencySymbol(aSymbol)) {
aSymbol.startsWith(DEFAULT_CURRENCY) && let symbol = aSymbol;
aSymbol.length > DEFAULT_CURRENCY.length symbol = symbol.replace('GBp', 'GBX');
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
)
) {
let symbol = aSymbol;
symbol = symbol.replace('GBp', 'GBX');
return `${symbol}.FOREX`; return `${symbol}.FOREX`;
}
} }
return aSymbol; return aSymbol;
@ -465,7 +455,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return searchResult; return searchResult;

49
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
@ -15,7 +16,11 @@ import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
REPLACE_NAME_PARTS REPLACE_NAME_PARTS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency, parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
isCurrencySymbol,
parseDate
} from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
@ -33,7 +38,6 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { isISIN } from 'class-validator'; import { isISIN } from 'class-validator';
import { countries } from 'countries-list';
import { import {
addDays, addDays,
addYears, addYears,
@ -55,6 +59,8 @@ export class FinancialModelingPrepService
'Taiwan (Province of China)': 'Taiwan' 'Taiwan (Province of China)': 'Taiwan'
}; };
private readonly logger = new Logger(FinancialModelingPrepService.name);
private apiKey: string; private apiKey: string;
public constructor( public constructor(
@ -84,9 +90,7 @@ export class FinancialModelingPrepService
}; };
try { try {
if ( if (isCurrencySymbol(symbol)) {
isCurrency(symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length))
) {
response.assetClass = AssetClass.LIQUIDITY; response.assetClass = AssetClass.LIQUIDITY;
response.assetSubClass = AssetSubClass.CASH; response.assetSubClass = AssetSubClass.CASH;
response.currency = symbol.substring( response.currency = symbol.substring(
@ -163,21 +167,11 @@ export class FinancialModelingPrepService
return countryName.toLowerCase() !== 'other'; return countryName.toLowerCase() !== 'other';
}) })
.map(({ country: countryName, weightPercentage }) => { .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 { return {
code: countryCode, code: getCountryCodeByName({
aliases: FinancialModelingPrepService.countriesMapping,
name: countryName
}),
weight: parseFloat(weightPercentage.slice(0, -1)) / 100 weight: parseFloat(weightPercentage.slice(0, -1)) / 100
}; };
}); });
@ -265,7 +259,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -325,12 +319,11 @@ export class FinancialModelingPrepService
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'FinancialModelingPrepService'
); );
return {}; return {};
@ -491,11 +484,7 @@ export class FinancialModelingPrepService
for (const { price, symbol } of quotes) { for (const { price, symbol } of quotes) {
let marketState: MarketState = 'delayed'; let marketState: MarketState = 'delayed';
if ( if (isCurrencySymbol(symbol)) {
isCurrency(
symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)
)
) {
marketState = 'open'; marketState = 'open';
} }
@ -518,7 +507,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -638,7 +627,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return { items }; return { items };

12
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -33,6 +33,8 @@ import { StatusCodes } from 'http-status-codes';
@Injectable() @Injectable()
export class GhostfolioService implements DataProviderInterface { export class GhostfolioService implements DataProviderInterface {
private readonly logger = new Logger(GhostfolioService.name);
private readonly URL = environment.production private readonly URL = environment.production
? 'https://ghostfol.io/api' ? 'https://ghostfol.io/api'
: `${this.configurationService.get('ROOT_URL')}/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.'; '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; 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.'; '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; 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.'; '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( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `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.'; '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; 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.'; '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; return searchResult;

4
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -24,6 +24,8 @@ import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable() @Injectable()
export class GoogleSheetsService implements DataProviderInterface { export class GoogleSheetsService implements DataProviderInterface {
private readonly logger = new Logger(GoogleSheetsService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -144,7 +146,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleSheetsService'); this.logger.error(error);
} }
return {}; return {};

9
apps/api/src/services/data-provider/manual/manual.service.ts

@ -31,6 +31,8 @@ import { addDays, format, isBefore } from 'date-fns';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
private readonly logger = new Logger(ManualService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService, private readonly fetchService: FetchService,
@ -181,9 +183,8 @@ export class ManualService implements DataProviderInterface {
}); });
return { marketPrice, symbol }; return { marketPrice, symbol };
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`, `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
'ManualService'
); );
return { symbol, marketPrice: undefined }; return { symbol, marketPrice: undefined };
} }
@ -216,7 +217,7 @@ export class ManualService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'ManualService'); this.logger.error(error);
} }
return {}; return {};

6
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

@ -26,6 +26,8 @@ import { format } from 'date-fns';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
private readonly logger = new Logger(RapidApiService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService private readonly fetchService: FetchService
@ -122,7 +124,7 @@ export class RapidApiService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); this.logger.error(error);
} }
return {}; return {};
@ -167,7 +169,7 @@ export class RapidApiService implements DataProviderInterface {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'RapidApiService'); this.logger.error(message);
return undefined; return undefined;
} }

23
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() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private readonly logger = new Logger(YahooFinanceService.name);
private readonly yahooFinance = new YahooFinance({ private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey'] suppressNotices: ['yahooSurvey']
}); });
@ -105,12 +107,11 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'YahooFinanceService'
); );
return {}; return {};
@ -198,12 +199,9 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols); quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
Logger.warn( this.logger.warn('Fallback to yahooFinance.quoteSummary()');
'Fallback to yahooFinance.quoteSummary()',
'YahooFinanceService'
);
quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols); quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols);
} }
@ -229,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
return {}; return {};
} }
@ -334,7 +332,7 @@ export class YahooFinanceService implements DataProviderInterface {
}); });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
} }
return { items }; return { items };
@ -365,10 +363,7 @@ export class YahooFinanceService implements DataProviderInterface {
.filter( .filter(
(result): result is PromiseFulfilledResult<QuoteSummaryResult> => { (result): result is PromiseFulfilledResult<QuoteSummaryResult> => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
Logger.error( this.logger.error(`Could not get quote summary: ${result.reason}`);
`Could not get quote summary: ${result.reason}`,
'YahooFinanceService'
);
return false; return false;
} }

19
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() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private readonly logger = new Logger(ExchangeRateDataService.name);
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: DataGatheringItem[] = []; private currencyPairs: DataGatheringItem[] = [];
private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; private derivedCurrencyFactors: { [currencyPair: string]: number } = {};
@ -110,9 +112,8 @@ export class ExchangeRateDataService {
previousExchangeRate; previousExchangeRate;
if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
Logger.error( this.logger.error(
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`
'ExchangeRateDataService'
); );
} }
} else { } else {
@ -253,9 +254,8 @@ export class ExchangeRateDataService {
} }
// Fallback with error, if currencies are not available // Fallback with error, if currencies are not available
Logger.error( this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
'ExchangeRateDataService'
); );
return aValue; return aValue;
@ -341,12 +341,11 @@ export class ExchangeRateDataService {
return factor * aValue; return factor * aValue;
} }
Logger.error( this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
aDate, aDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
'ExchangeRateDataService'
); );
return undefined; return undefined;
@ -483,7 +482,7 @@ export class ExchangeRateDataService {
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
} }
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); this.logger.error(`${errorMessage}.`);
} }
} }
} }

45
apps/api/src/services/fetch/fetch.service.ts

@ -3,6 +3,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { import {
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL,
PROPERTY_OPENROUTER_MODEL_WEB_FETCH,
PROPERTY_WEB_FETCH_ROUTES PROPERTY_WEB_FETCH_ROUTES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -15,6 +16,8 @@ import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable() @Injectable()
export class FetchService implements OnModuleInit { 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 REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token'];
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); 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 url = input instanceof Request ? input.url : input.toString();
const urlRedacted = this.redactUrl(url); const urlRedacted = this.redactUrl(url);
Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); this.logger.debug(`${method} ${urlRedacted}`);
if (method === 'GET') { if (method === 'GET') {
const webFetchRoute = this.getMatchingWebFetchRoute(url); const webFetchRoute = this.getMatchingWebFetchRoute(url);
@ -60,15 +63,11 @@ export class FetchService implements OnModuleInit {
return await globalThis.fetch(input, init); return await globalThis.fetch(input, init);
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Logger.error( this.logger.error(
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`, `${method} ${urlRedacted} failed: [${error.name}] ${error.message}`
'FetchService'
); );
} else { } else {
Logger.error( this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`);
`${method} ${urlRedacted} failed: ${String(error)}`,
'FetchService'
);
} }
throw error; throw error;
@ -82,12 +81,18 @@ export class FetchService implements OnModuleInit {
url: string; url: string;
webFetchRoute: WebFetchRoute; webFetchRoute: WebFetchRoute;
}) { }) {
const [openRouterApiKey, openRouterModel] = await Promise.all([ const [openRouterApiKey, openRouterModel, openRouterModelWebFetch] =
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER), await Promise.all([
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL) this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER),
]); this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL),
this.propertyService.getByKey<string>(
if (!openRouterApiKey || !openRouterModel) { PROPERTY_OPENROUTER_MODEL_WEB_FETCH
)
]);
const model = openRouterModelWebFetch || openRouterModel;
if (!model || !openRouterApiKey) {
return undefined; return undefined;
} }
@ -95,7 +100,7 @@ export class FetchService implements OnModuleInit {
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); const openRouterService = createOpenRouter({ apiKey: openRouterApiKey });
const { sources, text } = await generateText({ const { sources, text } = await generateText({
model: openRouterService.chat(openRouterModel), model: openRouterService.chat(model),
prompt: [ prompt: [
'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.', '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.', '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( this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`);
`Routed ${this.redactUrl(url)} via web fetch tool`,
'FetchService'
);
return new Response(body, { return new Response(body, {
headers: webFetchRoute.responseContentType headers: webFetchRoute.responseContentType
@ -159,11 +161,10 @@ export class FetchService implements OnModuleInit {
return undefined; return undefined;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Web fetch tool failed for ${this.redactUrl(url)}: ${ `Web fetch tool failed for ${this.redactUrl(url)}: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`, }`
'FetchService'
); );
return undefined; return undefined;

8
apps/api/src/services/i18n/i18n.service.ts

@ -7,6 +7,8 @@ import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService implements OnModuleInit { export class I18nService implements OnModuleInit {
private readonly logger = new Logger(I18nService.name);
private localesPath = join(__dirname, 'assets', 'locales'); private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {}; private translations: { [locale: string]: cheerio.CheerioAPI } = {};
@ -26,7 +28,7 @@ export class I18nService implements OnModuleInit {
const $ = this.translations[languageCode]; const $ = this.translations[languageCode];
if (!$) { if (!$) {
Logger.warn(`Translation not found for locale '${languageCode}'`); this.logger.warn(`Translation not found for locale '${languageCode}'`);
} }
let translatedText = $( let translatedText = $(
@ -36,7 +38,7 @@ export class I18nService implements OnModuleInit {
).text(); ).text();
if (!translatedText) { if (!translatedText) {
Logger.warn( this.logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'` `Translation not found for id '${id}' in locale '${languageCode}'`
); );
} }
@ -60,7 +62,7 @@ export class I18nService implements OnModuleInit {
this.parseXml(xmlData); this.parseXml(xmlData);
} }
} catch (error) { } catch (error) {
Logger.error(error, 'I18nService'); this.logger.error(error);
} }
} }

1
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_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string; API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string; API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;

4
apps/api/src/services/prisma/prisma.service.ts

@ -14,6 +14,8 @@ export class PrismaService
extends PrismaClient extends PrismaClient
implements OnModuleInit, OnModuleDestroy implements OnModuleInit, OnModuleDestroy
{ {
private readonly logger = new Logger(PrismaService.name);
public constructor(configService: ConfigService) { public constructor(configService: ConfigService) {
const adapter = new PrismaPg({ const adapter = new PrismaPg({
connectionString: configService.get<string>('DATABASE_URL') connectionString: configService.get<string>('DATABASE_URL')
@ -43,7 +45,7 @@ export class PrismaService
try { try {
await this.$connect(); await this.$connect();
} catch (error) { } catch (error) {
Logger.error(error, 'PrismaService'); this.logger.error(error);
} }
} }

3
apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -23,8 +23,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
adapter: BullAdapter, adapter: BullAdapter,
name: DATA_GATHERING_QUEUE, name: DATA_GATHERING_QUEUE,
options: { options: {
displayName: 'Data Gathering', displayName: 'Data Gathering'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}), }),
BullModule.registerQueue({ BullModule.registerQueue({

42
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -32,6 +32,8 @@ import { DataGatheringService } from './data-gathering.service';
@Injectable() @Injectable()
@Processor(DATA_GATHERING_QUEUE) @Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor { export class DataGatheringProcessor {
private readonly logger = new Logger(DataGatheringProcessor.name);
public constructor( public constructor(
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -51,16 +53,14 @@ export class DataGatheringProcessor {
const { dataSource, symbol } = job.data; const { dataSource, symbol } = job.data;
try { try {
Logger.log( this.logger.log(
`Asset profile data gathering has been started for ${symbol} (${dataSource})`, `Asset profile data gathering has been started for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
await this.dataGatheringService.gatherAssetProfiles([job.data]); await this.dataGatheringService.gatherAssetProfiles([job.data]);
Logger.log( this.logger.log(
`Asset profile data gathering has been completed for ${symbol} (${dataSource})`, `Asset profile data gathering has been completed for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
} catch (error) { } catch (error) {
if (error instanceof AssetProfileDelistedError) { if (error instanceof AssetProfileDelistedError) {
@ -74,18 +74,14 @@ export class DataGatheringProcessor {
} }
); );
Logger.log( this.logger.log(
`Asset profile data gathering has been discarded for ${symbol} (${dataSource})`, `Asset profile data gathering has been discarded for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
return job.discard(); return job.discard();
} }
Logger.error( this.logger.error(error);
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
throw error; throw error;
} }
@ -105,12 +101,11 @@ export class DataGatheringProcessor {
try { try {
let currentDate = parseISO(date as unknown as string); 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( `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}${force ? ' (forced update)' : ''}`, )}${force ? ' (forced update)' : ''}`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
@ -167,12 +162,11 @@ export class DataGatheringProcessor {
await this.marketDataService.updateMany({ data }); await this.marketDataService.updateMany({ data });
} }
Logger.log( this.logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format( `Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
} catch (error) { } catch (error) {
if (error instanceof AssetProfileDelistedError) { if (error instanceof AssetProfileDelistedError) {
@ -186,18 +180,14 @@ export class DataGatheringProcessor {
} }
); );
Logger.log( this.logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`, `Historical market data gathering has been discarded for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
return job.discard(); return job.discard();
} }
Logger.error( this.logger.error(error);
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw error; throw error;
} }

44
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -34,6 +34,8 @@ import ms, { StringValue } from 'ms';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
private readonly logger = new Logger(DataGatheringService.name);
public constructor( public constructor(
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
@ -145,7 +147,7 @@ export class DataGatheringService {
}); });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataGatheringService'); this.logger.error(error);
} finally { } finally {
return undefined; return undefined;
} }
@ -176,30 +178,42 @@ export class DataGatheringService {
); );
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolProfile = symbolProfiles.find(
return symbolProfile.symbol === symbol; ({ symbol: symbolProfileSymbol }) => {
})?.symbolMapping; 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) { for (const dataEnhancer of this.dataEnhancers) {
try { try {
assetProfiles[symbol] = await dataEnhancer.enhance({ enhancedAssetProfile = await dataEnhancer.enhance({
response: assetProfile, response: enhancedAssetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Failed to enhance data for ${symbol} (${ `Failed to enhance data for ${symbol} (${
assetProfile.dataSource assetProfile.dataSource
}) by ${dataEnhancer.getName()}`, }) by ${dataEnhancer.getName()}`,
error, error
'DataGatheringService'
); );
} }
} }
const { assetClass, assetSubClass } = assetProfile;
const { const {
assetClass,
assetSubClass,
countries, countries,
currency, currency,
cusip, cusip,
@ -212,7 +226,7 @@ export class DataGatheringService {
name, name,
sectors, sectors,
url url
} = assetProfile; } = enhancedAssetProfile;
try { try {
await this.prismaService.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
@ -256,11 +270,7 @@ export class DataGatheringService {
} }
}); });
} catch (error) { } catch (error) {
Logger.error( this.logger.error(`${symbol}: ${error?.meta?.cause}`, error);
`${symbol}: ${error?.meta?.cause}`,
error,
'DataGatheringService'
);
if (assetProfileIdentifiers.length === 1) { if (assetProfileIdentifiers.length === 1) {
throw error; throw error;

3
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -29,8 +29,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
adapter: BullAdapter, adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: { options: {
displayName: 'Portfolio Snapshot Computation', displayName: 'Portfolio Snapshot Computation'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}), }),
BullModule.registerQueue({ BullModule.registerQueue({

17
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -21,6 +21,8 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue
@Injectable() @Injectable()
@Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) @Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
export class PortfolioSnapshotProcessor { export class PortfolioSnapshotProcessor {
private readonly logger = new Logger(PortfolioSnapshotProcessor.name);
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
@ -41,9 +43,8 @@ export class PortfolioSnapshotProcessor {
try { try {
const startTime = performance.now(); const startTime = performance.now();
Logger.log( this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`, `Portfolio snapshot calculation of user '${job.data.userId}' has been started`
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
); );
const { activities } = const { activities } =
@ -72,12 +73,11 @@ export class PortfolioSnapshotProcessor {
const snapshot = await portfolioCalculator.computeSnapshot(); const snapshot = await portfolioCalculator.computeSnapshot();
Logger.log( this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( `Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${(
(performance.now() - startTime) / (performance.now() - startTime) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
); );
const expiration = addMilliseconds( const expiration = addMilliseconds(
@ -101,10 +101,7 @@ export class PortfolioSnapshotProcessor {
return snapshot; return snapshot;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(error);
error,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
);
throw new Error(error); throw new Error(error);
} }

3
apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts

@ -20,8 +20,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
adapter: BullAdapter, adapter: BullAdapter,
name: STATISTICS_GATHERING_QUEUE, name: STATISTICS_GATHERING_QUEUE,
options: { options: {
displayName: 'Statistics Gathering', displayName: 'Statistics Gathering'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}) })
] ]

55
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -27,6 +27,8 @@ import { format, subDays } from 'date-fns';
@Injectable() @Injectable()
@Processor(STATISTICS_GATHERING_QUEUE) @Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor { export class StatisticsGatheringProcessor {
private readonly logger = new Logger(StatisticsGatheringProcessor.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService, private readonly fetchService: FetchService,
@ -35,10 +37,7 @@ export class StatisticsGatheringProcessor {
@Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME)
public async gatherDockerHubPullsStatistics() { public async gatherDockerHubPullsStatistics() {
Logger.log( this.logger.log('Docker Hub pulls statistics gathering has been started');
'Docker Hub pulls statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const dockerHubPulls = await this.countDockerHubPulls(); const dockerHubPulls = await this.countDockerHubPulls();
@ -47,17 +46,13 @@ export class StatisticsGatheringProcessor {
value: String(dockerHubPulls) value: String(dockerHubPulls)
}); });
Logger.log( this.logger.log('Docker Hub pulls statistics gathering has been completed');
'Docker Hub pulls statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
} }
@Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME)
public async gatherGitHubContributorsStatistics() { public async gatherGitHubContributorsStatistics() {
Logger.log( this.logger.log(
'GitHub contributors statistics gathering has been started', 'GitHub contributors statistics gathering has been started'
'StatisticsGatheringProcessor'
); );
const gitHubContributors = await this.countGitHubContributors(); const gitHubContributors = await this.countGitHubContributors();
@ -67,18 +62,14 @@ export class StatisticsGatheringProcessor {
value: String(gitHubContributors) value: String(gitHubContributors)
}); });
Logger.log( this.logger.log(
'GitHub contributors statistics gathering has been completed', 'GitHub contributors statistics gathering has been completed'
'StatisticsGatheringProcessor'
); );
} }
@Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME)
public async gatherGitHubStargazersStatistics() { public async gatherGitHubStargazersStatistics() {
Logger.log( this.logger.log('GitHub stargazers statistics gathering has been started');
'GitHub stargazers statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
@ -87,9 +78,8 @@ export class StatisticsGatheringProcessor {
value: String(gitHubStargazers) value: String(gitHubStargazers)
}); });
Logger.log( this.logger.log(
'GitHub stargazers statistics gathering has been completed', 'GitHub stargazers statistics gathering has been completed'
'StatisticsGatheringProcessor'
); );
} }
@ -100,18 +90,14 @@ export class StatisticsGatheringProcessor {
); );
if (!monitorId) { if (!monitorId) {
Logger.log( this.logger.log(
`Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`, `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`
'StatisticsGatheringProcessor'
); );
return; return;
} }
Logger.log( this.logger.log('Uptime statistics gathering has been started');
'Uptime statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const uptime = await this.getUptime(monitorId); const uptime = await this.getUptime(monitorId);
@ -120,10 +106,7 @@ export class StatisticsGatheringProcessor {
value: String(uptime) value: String(uptime)
}); });
Logger.log( this.logger.log('Uptime statistics gathering has been completed');
'Uptime statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
} }
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
@ -139,7 +122,7 @@ export class StatisticsGatheringProcessor {
return pull_count; return pull_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - DockerHub'); this.logger.error(error);
throw error; throw error;
} }
@ -169,7 +152,7 @@ export class StatisticsGatheringProcessor {
value value
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); this.logger.error(error);
throw error; throw error;
} }
@ -188,7 +171,7 @@ export class StatisticsGatheringProcessor {
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); this.logger.error(error);
throw error; throw error;
} }
@ -217,7 +200,7 @@ export class StatisticsGatheringProcessor {
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - Better Stack'); this.logger.error(error);
throw error; throw error;
} }

87
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -1,5 +1,6 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { applyAssetProfileOverrides } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
@ -10,7 +11,12 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; 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'; import { continents, countries } from 'countries-list';
@Injectable() @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( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
@ -192,21 +218,28 @@ export class SymbolProfileService {
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => { return symbolProfiles.map((symbolProfile) => {
const symbolProfileWithOverrides = applyAssetProfileOverrides(
symbolProfile,
symbolProfile.SymbolProfileOverrides
);
const item = { const item = {
...symbolProfile, ...symbolProfileWithOverrides,
activitiesCount: 0, activitiesCount: 0,
countries: this.getCountries( countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfileWithOverrides?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: undefined as Date, dateOfFirstActivity: undefined as Date,
holdings: this.getHoldings( 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( 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 watchedByCount: 0
}; };
@ -217,45 +250,7 @@ export class SymbolProfileService {
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
delete item.activities; delete item.activities;
if (item.SymbolProfileOverrides) { delete 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;
}
return item; return item;
}); });

9
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -16,6 +16,8 @@ import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable() @Injectable()
export class TwitterBotService implements OnModuleInit { export class TwitterBotService implements OnModuleInit {
private readonly logger = new Logger(TwitterBotService.name);
private twitterClient: TwitterApiReadWrite; private twitterClient: TwitterApiReadWrite;
public constructor( public constructor(
@ -71,13 +73,12 @@ export class TwitterBotService implements OnModuleInit {
const { data: createdTweet } = const { data: createdTweet } =
await this.twitterClient.v2.tweet(status); await this.twitterClient.v2.tweet(status);
Logger.log( this.logger.log(
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`, `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`
'TwitterBotService'
); );
} }
} catch (error) { } catch (error) {
Logger.error(error, 'TwitterBotService'); this.logger.error(error);
} }
} }

10
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 { import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
@ -245,7 +245,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.balance = balance; this.balance = balance;
if ( if (
this.balance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && this.balance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES &&
this.data.deviceType === 'mobile' this.data.deviceType === 'mobile'
) { ) {
this.balancePrecision = 0; this.balancePrecision = 0;
@ -257,7 +257,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.dividendInBaseCurrencyPrecision = 0; this.dividendInBaseCurrencyPrecision = 0;
} }
@ -267,7 +267,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.equity >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.equity >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.equityPrecision = 0; this.equityPrecision = 0;
} }
@ -280,7 +280,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.interestInBaseCurrency >= this.interestInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.interestInBaseCurrencyPrecision = 0; this.interestInBaseCurrencyPrecision = 0;
} }

1
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -148,6 +148,7 @@
[accountBalances]="accountBalances" [accountBalances]="accountBalances"
[accountCurrency]="currency" [accountCurrency]="currency"
[accountId]="data.accountId" [accountId]="data.accountId"
[currentBalance]="balance"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
!data.hasImpersonationId && !data.hasImpersonationId &&

23
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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -10,7 +7,6 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces'; import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
@ -106,7 +102,6 @@ export class GfAdminJobsComponent implements OnInit {
'actions' 'actions'
]; ];
protected hasPermissionToAccessBullBoard = false;
protected isLoading = false; protected isLoading = false;
protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST; protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST;
@ -116,7 +111,6 @@ export class GfAdminJobsComponent implements OnInit {
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
@ -129,11 +123,6 @@ export class GfAdminJobsComponent implements OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString( this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale 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']) { protected onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aData, null, ' ') title: JSON.stringify(aData, null, ' ')

9
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -1,15 +1,6 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@if (hasPermissionToAccessBullBoard) {
<div class="d-flex justify-content-end mb-3">
<button mat-stroked-button (click)="onOpenBullBoard()">
<span><ng-container i18n>Overview</ng-container></span>
<ion-icon class="ml-2" name="open-outline" />
</button>
</div>
}
<form class="align-items-center d-flex" [formGroup]="filterForm"> <form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status"> <mat-select formControlName="status">

26
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -1,8 +1,8 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_COLOR_SCHEME, DEFAULT_COLOR_SCHEME,
DEFAULT_PAGE_SIZE, DEFAULT_LOCALE,
locale DEFAULT_PAGE_SIZE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
@ -10,11 +10,11 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
AssetProfileItem,
Filter, Filter,
InfoItem, InfoItem,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
@ -152,7 +152,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
} }
]; ];
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected dataSource = new MatTableDataSource<AdminMarketDataItem>(); protected dataSource = new MatTableDataSource<AssetProfileItem>();
protected defaultDateFormat: string; protected defaultDateFormat: string;
protected readonly displayedColumns: string[] = []; protected readonly displayedColumns: string[] = [];
protected readonly filters$ = new Subject<Filter[]>(); protected readonly filters$ = new Subject<Filter[]>();
@ -160,7 +160,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
protected readonly isUUID = isUUID; protected readonly isUUID = isUUID;
protected pageSize = DEFAULT_PAGE_SIZE; protected pageSize = DEFAULT_PAGE_SIZE;
protected placeholder = ''; protected placeholder = '';
protected readonly selection = new SelectionModel<AdminMarketDataItem>(true); protected readonly selection = new SelectionModel<AssetProfileItem>(true);
protected totalItems = 0; protected totalItems = 0;
protected user: User; protected user: User;
@ -375,8 +375,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
this.selection.clear(); this.selection.clear();
this.adminService this.dataService
.fetchAdminMarketData({ .fetchAssetProfiles({
sortColumn, sortColumn,
sortDirection, sortDirection,
filters: this.activeFilters, filters: this.activeFilters,
@ -384,15 +384,15 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
take: this.pageSize take: this.pageSize
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, marketData }) => { .subscribe(({ assetProfiles, count }) => {
this.totalItems = count; this.totalItems = count;
this.dataSource = new MatTableDataSource( this.dataSource = new MatTableDataSource(
marketData.map((marketDataItem) => { assetProfiles.map((assetProfile) => {
return { return {
...marketDataItem, ...assetProfile,
isBenchmark: this.benchmarks.some(({ id }) => { isBenchmark: this.benchmarks.some(({ id }) => {
return id === marketDataItem.id; return id === assetProfile.id;
}) })
}; };
}) })
@ -429,7 +429,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
colorScheme: colorScheme:
this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
deviceType: this.deviceType(), deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies AssetProfileDialogParams, } satisfies AssetProfileDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
@ -464,7 +464,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType(), deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies CreateAssetProfileDialogParams, } satisfies CreateAssetProfileDialogParams,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });

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

@ -239,7 +239,18 @@
[disabled]="!selection.hasValue()" [disabled]="!selection.hasValue()"
(click)="onDeleteAssetProfiles()" (click)="onDeleteAssetProfiles()"
> >
<ng-container i18n>Delete Profiles</ng-container> <ng-container i18n
>Delete
{{
selection.selected.length > 1
? selection.selected.length
: ''
}}
{selection.selected.length, plural,
=1 {Profile}
other {Profiles}
}</ng-container
>
</button> </button>
</mat-menu> </mat-menu>
</th> </th>

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

@ -32,6 +32,8 @@ export class AdminMarketDataService {
public deleteAssetProfiles( public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
) { ) {
const assetProfileCount = aAssetProfileIdentifiers.length;
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
const deleteRequests = aAssetProfileIdentifiers.map( const deleteRequests = aAssetProfileIdentifiers.map(
@ -44,7 +46,10 @@ export class AdminMarketDataService {
.pipe( .pipe(
catchError(() => { catchError(() => {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not delete profiles.` title:
assetProfileCount === 1
? $localize`Oops! Could not delete the asset profile.`
: $localize`Oops! Could not delete the asset profiles.`
}); });
return EMPTY; return EMPTY;
@ -56,7 +61,10 @@ export class AdminMarketDataService {
.subscribe(); .subscribe();
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these profiles?` title:
assetProfileCount === 1
? $localize`Do you really want to delete this asset profile?`
: $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?`
}); });
} }
} }

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss

@ -14,4 +14,8 @@
top: 0; top: 0;
} }
} }
.mat-mdc-dialog-title {
padding-right: 0.5rem !important;
}
} }

10
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 { import {
canDeleteAssetProfile, canDeleteAssetProfile,
DATE_FORMAT, DATE_FORMAT,
getCountryName,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency isCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -224,6 +225,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max' value: 'max'
} }
]; ];
protected readonly getCountryName = getCountryName;
protected historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
protected isBenchmark = false; protected isBenchmark = false;
protected isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
@ -246,6 +248,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected readonly translate = translate;
protected user: User; protected user: User;
private benchmarks: Partial<SymbolProfile>[]; private benchmarks: Partial<SymbolProfile>[];
@ -367,9 +371,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfile?.countries && this.assetProfile?.countries &&
this.assetProfile.countries.length > 0 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] = { this.countries[code] = {
name, name: getCountryName({ code }),
value: weight value: weight
}; };
} }
@ -381,7 +385,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) { ) {
for (const { name, weight } of this.assetProfile.sectors) { for (const { name, weight } of this.assetProfile.sectors) {
this.sectors[name] = { this.sectors[name] = {
name, name: translate(name),
value: weight value: weight
}; };
} }

18
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -1,10 +1,10 @@
<div class="d-flex flex-column h-100"> <div class="d-flex flex-column h-100">
<div class="d-flex mb-3"> <div class="align-items-center d-flex mb-3" mat-dialog-title>
<h1 class="flex-grow-1 m-0" mat-dialog-title> <span class="flex-grow-1 text-truncate">{{
<span>{{ assetProfile?.name ?? data.symbol }}</span> assetProfile?.name ?? data.symbol
</h1> }}</span>
<button <button
class="mx-1 no-min-width px-2" class="ml-1 no-min-width px-2"
mat-button mat-button
type="button" type="button"
[matMenuTriggerFor]="assetProfileActionsMenu" [matMenuTriggerFor]="assetProfileActionsMenu"
@ -258,7 +258,7 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.sectors[0].name" [value]="translate(assetProfile?.sectors[0].name)"
>Sector</gf-value >Sector</gf-value
> >
</div> </div>
@ -269,7 +269,11 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.countries[0].name" [value]="
getCountryName({
code: assetProfile?.countries[0].code
})
"
>Country</gf-value >Country</gf-value
> >
</div> </div>

5
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -1,7 +1,7 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper'; import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService, DataService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -33,7 +33,6 @@ import {
ellipsisHorizontal, ellipsisHorizontal,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component'; import { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
@ -147,7 +146,7 @@ export class GfAdminPlatformComponent implements OnInit {
this.dataSource = new MatTableDataSource(platforms); this.dataSource = new MatTableDataSource(platforms);
this.dataSource.sort = this.sort(); this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = getLowercase;
this.dataService.updateInfo(); this.dataService.updateInfo();

5
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -1,7 +1,7 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper'; import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -32,7 +32,6 @@ import {
ellipsisHorizontal, ellipsisHorizontal,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateOrUpdateTagDialogComponent } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component'; import { GfCreateOrUpdateTagDialogComponent } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
@ -149,7 +148,7 @@ export class GfAdminTagComponent implements OnInit {
this.dataSource = new MatTableDataSource(this.tags); this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort(); this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = getLowercase;
this.dataService.updateInfo(); this.dataService.updateInfo();

26
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -5,7 +5,7 @@ import {
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component'; import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { import {
getDateFnsLocale, getDateFnsLocale,
@ -59,8 +59,10 @@ import {
personOutline, personOutline,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { interval } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators'; import { switchMap, tap } from 'rxjs/operators';
@Component({ @Component({
@ -184,6 +186,15 @@ export class GfAdminUsersComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.fetchUsers(); this.fetchUsers();
interval(ms('30 seconds'))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchUsers({
pageIndex: this.paginator().pageIndex,
showLoading: false
});
});
} }
protected formatDistanceToNow(aDateString: string) { protected formatDistanceToNow(aDateString: string) {
@ -267,8 +278,13 @@ export class GfAdminUsersComponent implements OnInit {
); );
} }
private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) { private fetchUsers({
this.isLoading = true; pageIndex = 0,
showLoading = true
}: { pageIndex?: number; showLoading?: boolean } = {}) {
if (showLoading) {
this.isLoading = true;
}
if (pageIndex === 0 && this.paginator()) { if (pageIndex === 0 && this.paginator()) {
this.paginator().pageIndex = 0; this.paginator().pageIndex = 0;
@ -281,7 +297,7 @@ export class GfAdminUsersComponent implements OnInit {
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, users }) => { .subscribe(({ count, users }) => {
this.dataSource = new MatTableDataSource(users); this.dataSource.data = users;
this.totalItems = count; this.totalItems = count;
this.isLoading = false; this.isLoading = false;
@ -301,7 +317,7 @@ export class GfAdminUsersComponent implements OnInit {
currentUserId: this.user?.id, currentUserId: this.user?.id,
deviceType: this.deviceType(), deviceType: this.deviceType(),
hasPermissionForSubscription: this.hasPermissionForSubscription, hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale ?? locale, locale: this.user?.settings?.locale ?? DEFAULT_LOCALE,
userId: aUserId userId: aUserId
} satisfies UserDetailDialogParams, } satisfies UserDetailDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '60vh', height: this.deviceType() === 'mobile' ? '98vh' : '60vh',

6
apps/client/src/app/components/admin-users/admin-users.html

@ -16,7 +16,7 @@
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2"
mat-cell mat-cell
> >
<div class="d-flex align-items-center"> <div class="align-items-center d-flex text-nowrap">
<span <span
class="d-none d-sm-inline-block text-monospace" class="d-none d-sm-inline-block text-monospace"
[class.text-line-through]="element.role === 'INACTIVE'" [class.text-line-through]="element.role === 'INACTIVE'"
@ -77,7 +77,7 @@
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell mat-cell
> >
{{ formatDistanceToNow(element.createdAt) }} {{ formatDistanceToNow(element.createdAt) }}
@ -183,7 +183,7 @@
</th> </th>
<td <td
*matCellDef="let element" *matCellDef="let element"
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2 text-nowrap"
mat-cell mat-cell
> >
{{ formatDistanceToNow(element.lastActivity) }} {{ formatDistanceToNow(element.lastActivity) }}

2
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -53,6 +53,6 @@
<canvas <canvas
#chartCanvas #chartCanvas
class="h-100" class="h-100"
[ngStyle]="{ display: isLoading() ? 'none' : 'block' }" [style.display]="isLoading() ? 'none' : 'block'"
></canvas> ></canvas>
</div> </div>

4
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 { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -45,7 +44,6 @@ import {
type TooltipOptions type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons'; import { arrowForwardOutline } from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -53,7 +51,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,
@ -88,7 +85,6 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin,
LinearScale, LinearScale,
LineController, LineController,
LineElement, LineElement,

12
apps/client/src/app/components/footer/footer.component.html

@ -141,13 +141,11 @@
<li> <li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a> <a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li> </li>
<!-- <li>
<li> <a href="../ko" title="Ghostfolio in Korean (한국어)"
<a href="../ko" title="Ghostfolio in Korean (한국어)" >Korean (한국어)</a
>Korean (한국어)</a >
> </li>
</li>
-->
<li> <li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a> <a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li> </li>

231
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -2,17 +2,21 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES, NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
downloadAsFile,
getCountryName
} from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
DataProviderInfo, DataProviderInfo,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
LineChartItem, LineChartItem,
NullableLineChartItem,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,11 +40,16 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
Inject, OnInit,
OnInit inject
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { import {
@ -68,6 +77,7 @@ import {
swapVerticalOutline, swapVerticalOutline,
walletOutline walletOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@ -103,73 +113,78 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnInit { export class GfHoldingDetailDialogComponent implements OnInit {
public activitiesCount: number; protected accounts: Account[];
public accounts: Account[]; protected activitiesCount: number;
public assetClass: string; protected assetClass: string;
public assetSubClass: string; protected assetSubClass: string;
public averagePrice: number; protected averagePrice: number;
public averagePricePrecision = 2; protected averagePricePrecision = 2;
public benchmarkDataItems: LineChartItem[]; protected benchmarkDataItems: NullableLineChartItem[];
public benchmarkLabel = $localize`Average Unit Price`; protected readonly benchmarkLabel = $localize`Average Unit Price`;
public countries: { protected countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public dataProviderInfo: DataProviderInfo; protected dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>; protected dataSource: MatTableDataSource<Activity>;
public dateOfFirstActivity: string; protected dateOfFirstActivity: Date;
public dividendInBaseCurrency: number; protected dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2; protected dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; protected dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; protected feeInBaseCurrency: number;
public hasPermissionToCreateOwnTag: boolean; protected readonly getCountryName = getCountryName;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; protected hasPermissionToCreateOwnTag: boolean;
public historicalDataItems: LineChartItem[]; protected hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public holdingForm: FormGroup; protected historicalDataItems: LineChartItem[];
public investmentInBaseCurrencyWithCurrencyEffect: number; protected holdingForm: FormGroup<{
public investmentInBaseCurrencyWithCurrencyEffectPrecision = 2; tags: FormControl<Tag[] | null>;
public isUUID = isUUID; }>;
public marketDataItems: MarketData[] = []; protected investmentInBaseCurrencyWithCurrencyEffect: number;
public marketPrice: number; protected investmentInBaseCurrencyWithCurrencyEffectPrecision = 2;
public marketPriceMax: number; protected readonly isUUID = isUUID;
public marketPriceMaxPrecision = 2; protected marketDataItems: MarketData[] = [];
public marketPriceMin: number; protected marketPrice: number;
public marketPriceMinPrecision = 2; protected marketPriceMax: number;
public marketPricePrecision = 2; protected marketPriceMaxPrecision = 2;
public netPerformance: number; protected marketPriceMin: number;
public netPerformancePrecision = 2; protected marketPriceMinPrecision = 2;
public netPerformancePercent: number; protected marketPricePrecision = 2;
public netPerformancePercentWithCurrencyEffect: number; protected netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercentWithCurrencyEffectPrecision = 2; protected netPerformancePercentWithCurrencyEffectPrecision = 2;
public netPerformanceWithCurrencyEffect: number; protected netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2; protected netPerformanceWithCurrencyEffectPrecision = 2;
public pageIndex = 0; protected pageIndex = 0;
public pageSize = DEFAULT_PAGE_SIZE; protected readonly pageSize = DEFAULT_PAGE_SIZE;
public quantity: number; protected quantity: number;
public quantityPrecision = 2; protected quantityPrecision = 2;
public reportDataGlitchMail: string; protected reportDataGlitchMail: string;
public routerLinkAdminControlMarketData = protected readonly routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink; internalRoutes.adminControl.subRoutes.marketData.routerLink;
public sectors: { protected sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public sortColumn = 'date'; protected sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; protected sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile; protected SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; protected tagsAvailable: Tag[];
public tagsAvailable: Tag[]; protected readonly translate = translate;
public user: User; protected user: User;
public value: number; protected value: number;
public constructor( protected readonly data = inject<HoldingDetailDialogParams>(MAT_DIALOG_DATA);
private changeDetectorRef: ChangeDetectorRef, protected readonly dialogRef = inject(
private dataService: DataService, MatDialogRef<GfHoldingDetailDialogComponent>
private destroyRef: DestroyRef, );
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, private tags: Tag[];
private formBuilder: FormBuilder,
private router: Router, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private userService: UserService private readonly dataService = inject(DataService);
) { private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
addIcons({ addIcons({
arrowDownCircleOutline, arrowDownCircleOutline,
createOutline, createOutline,
@ -185,12 +200,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
const filters = this.getActivityFilters(); const filters = this.getActivityFilters();
this.holdingForm = this.formBuilder.group({ this.holdingForm = this.formBuilder.group({
tags: [] as string[] tags: new FormControl<Tag[]>([])
}); });
this.holdingForm this.holdingForm.controls.tags.valueChanges
.get('tags') .pipe(takeUntilDestroyed(this.destroyRef))
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => { .subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => { const newTag = tags.find(({ id }) => {
return id === undefined; return id === undefined;
@ -263,8 +277,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
marketPrice, marketPrice,
marketPriceMax, marketPriceMax,
marketPriceMin, marketPriceMin,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect, netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
quantity, quantity,
@ -276,7 +288,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
if ( if (
this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES &&
this.data.deviceType === 'mobile' this.data.deviceType === 'mobile'
) { ) {
this.averagePricePrecision = 0; this.averagePricePrecision = 0;
@ -285,13 +297,17 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.countries = {}; this.countries = {};
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
this.dateOfFirstActivity = dateOfFirstActivity;
if (dateOfFirstActivity) {
this.dateOfFirstActivity = dateOfFirstActivity;
}
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.dividendInBaseCurrencyPrecision = 0; this.dividendInBaseCurrencyPrecision = 0;
} }
@ -313,12 +329,12 @@ export class GfHoldingDetailDialogComponent implements OnInit {
({ averagePrice, date, marketPrice }) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
date, date,
value: averagePrice value: isNumber(averagePrice) ? averagePrice : null
}); });
return { return {
date, date,
value: marketPrice value: marketPrice ?? 0
}; };
} }
); );
@ -329,7 +345,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.investmentInBaseCurrencyWithCurrencyEffect >= this.investmentInBaseCurrencyWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0; this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0;
} }
@ -339,7 +355,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPriceMaxPrecision = 0; this.marketPriceMaxPrecision = 0;
} }
@ -348,29 +364,18 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPriceMinPrecision = 0; this.marketPriceMinPrecision = 0;
} }
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPricePrecision = 0; this.marketPricePrecision = 0;
} }
this.netPerformance = netPerformance;
if (
this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.netPerformancePrecision = 0;
}
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect = this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect; netPerformancePercentWithCurrencyEffect;
@ -433,7 +438,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (SymbolProfile?.countries?.length > 0) { if (SymbolProfile?.countries?.length > 0) {
for (const country of SymbolProfile.countries) { for (const country of SymbolProfile.countries) {
this.countries[country.code] = { this.countries[country.code] = {
name: country.name, name: getCountryName({ code: country.code }),
value: country.weight value: country.weight
}; };
} }
@ -442,22 +447,22 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (SymbolProfile?.sectors?.length > 0) { if (SymbolProfile?.sectors?.length > 0) {
for (const sector of SymbolProfile.sectors) { for (const sector of SymbolProfile.sectors) {
this.sectors[sector.name] = { this.sectors[sector.name] = {
name: sector.name, name: translate(sector.name),
value: sector.weight value: sector.weight
}; };
} }
} }
if (isToday(parseISO(this.dateOfFirstActivity))) { if (isToday(this.dateOfFirstActivity)) {
// Add average price // Add average price
this.historicalDataItems.push({ this.historicalDataItems.push({
date: this.dateOfFirstActivity, date: this.dateOfFirstActivity.toISOString(),
value: this.averagePrice value: this.averagePrice
}); });
// Add benchmark 1 // Add benchmark 1
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
date: this.dateOfFirstActivity, date: this.dateOfFirstActivity.toISOString(),
value: averagePrice value: averagePrice
}); });
@ -488,7 +493,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.benchmarkDataItems[0]?.value === undefined && this.benchmarkDataItems[0]?.value === undefined &&
isSameMonth(parseISO(this.dateOfFirstActivity), new Date()) isSameMonth(this.dateOfFirstActivity, new Date())
) { ) {
this.benchmarkDataItems[0].value = this.averagePrice; this.benchmarkDataItems[0].value = this.averagePrice;
} }
@ -518,7 +523,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag =
hasPermission(this.user.permissions, permissions.createOwnTag) && hasPermission(this.user.permissions, permissions.createOwnTag) &&
this.user?.settings?.isExperimentalFeatures; (this.user?.settings?.isExperimentalFeatures ?? false);
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags?.map((tag) => {
@ -533,13 +538,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}); });
} }
public onChangePage(page: PageEvent) { protected onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex; this.pageIndex = page.pageIndex;
this.fetchActivities(); this.fetchActivities();
} }
public onCloneActivity(aActivity: Activity) { protected onCloneActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {
@ -550,22 +555,22 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onCloseHolding() { protected onCloseHolding() {
const today = new Date(); const today = new Date();
const activity: CreateOrderDto = { const activity: CreateOrderDto = {
accountId: this.accounts.length === 1 ? this.accounts[0].id : null, accountId: this.accounts.length === 1 ? this.accounts[0].id : undefined,
comment: null, comment: undefined,
currency: this.SymbolProfile.currency, currency: this.SymbolProfile?.currency ?? '',
dataSource: this.SymbolProfile.dataSource, dataSource: this.SymbolProfile?.dataSource,
date: today.toISOString(), date: today.toISOString(),
fee: 0, fee: 0,
quantity: this.quantity, quantity: this.quantity,
symbol: this.SymbolProfile.symbol, symbol: this.SymbolProfile?.symbol ?? '',
tags: this.tags.map(({ id }) => { tags: this.tags.map(({ id }) => {
return id; return id;
}), }),
@ -585,7 +590,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}); });
} }
public onExport() { protected onExport() {
const activityIds = this.dataSource.data.map(({ id }) => { const activityIds = this.dataSource.data.map(({ id }) => {
return id; return id;
}); });
@ -605,13 +610,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}); });
} }
public onMarketDataChanged(withRefresh = false) { protected onMarketDataChanged(withRefresh = false) {
if (withRefresh) { if (withRefresh) {
this.fetchMarketData(); this.fetchMarketData();
} }
} }
public onUpdateActivity(aActivity: Activity) { protected onUpdateActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
{ {

8
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -262,7 +262,7 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="SymbolProfile.sectors[0].name" [value]="translate(SymbolProfile.sectors[0].name)"
>Sector</gf-value >Sector</gf-value
> >
</div> </div>
@ -272,7 +272,11 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="SymbolProfile.countries[0].name" [value]="
getCountryName({
code: SymbolProfile.countries[0].code
})
"
>Country</gf-value >Country</gf-value
> >
</div> </div>

4
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -1,6 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { locale as defaultLocale } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
@ -149,7 +149,7 @@ export class GfHomeWatchlistComponent implements OnInit {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType(), deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? defaultLocale locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
}, },
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });

2
apps/client/src/app/components/investment-chart/investment-chart.component.html

@ -10,5 +10,5 @@
<canvas <canvas
#chartCanvas #chartCanvas
class="h-100" class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }" [style.display]="isLoading ? 'none' : 'block'"
></canvas> ></canvas>

8
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 { ColorScheme, GroupBy } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart'; import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -41,15 +40,13 @@ import {
type TooltipOptions type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin, { import { type AnnotationOptions } from 'chartjs-plugin-annotation';
type AnnotationOptions
} from 'chartjs-plugin-annotation';
import { isAfter } from 'date-fns'; import { isAfter } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [NgxSkeletonLoaderModule],
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
styleUrls: ['./investment-chart.component.scss'], styleUrls: ['./investment-chart.component.scss'],
templateUrl: './investment-chart.component.html' templateUrl: './investment-chart.component.html'
@ -75,7 +72,6 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() { public constructor() {
Chart.register( Chart.register(
annotationPlugin,
BarController, BarController,
BarElement, BarElement,
LinearScale, LinearScale,

30
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 { GfValueComponent } from '@ghostfolio/ui/value';
import { Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@ -14,22 +13,37 @@ import { MatSliderModule } from '@angular/material/slider';
import { RuleSettingsDialogParams } from './interfaces/interfaces'; import { RuleSettingsDialogParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatSliderModule MatSliderModule,
ReactiveFormsModule
], ],
selector: 'gf-rule-settings-dialog', selector: 'gf-rule-settings-dialog',
styleUrls: ['./rule-settings-dialog.scss'], styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html' templateUrl: './rule-settings-dialog.html'
}) })
export class GfRuleSettingsDialogComponent { export class GfRuleSettingsDialogComponent {
public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; public settingsForm: FormGroup;
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams, @Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent> public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>,
) {} 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
});
}
} }

262
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,132 +1,142 @@
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div> <div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content> <form
@if ( class="d-flex flex-column h-100"
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax [formGroup]="settingsForm"
) { (ngSubmit)="onSubmit()"
<div class="w-100"> >
<h6 class="d-flex mb-0"> <div class="py-3" mat-dialog-content>
<ng-container i18n>Threshold range</ng-container>: @if (
<gf-value data.rule.configuration.thresholdMin &&
class="ml-1" data.rule.configuration.thresholdMax
[isPercent]="data.rule.configuration.threshold.unit === '%'" ) {
[locale]="data.locale" <div class="w-100">
[precision]="2" <h6 class="d-flex mb-0">
[value]="data.settings.thresholdMin" <ng-container i18n>Threshold range</ng-container>:
/> <gf-value
<span class="mx-1">-</span> class="ml-1"
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="settingsForm.get('thresholdMin').value"
[value]="data.settings.thresholdMax" />
/> <span class="mx-1">-</span>
</h6> <gf-value
<div class="align-items-center d-flex w-100"> [isPercent]="data.rule.configuration.threshold.unit === '%'"
<gf-value [locale]="data.locale"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [precision]="2"
[locale]="data.locale" [value]="settingsForm.get('thresholdMax').value"
[precision]="2" />
[value]="data.rule.configuration.threshold.min" </h6>
/> <div class="align-items-center d-flex w-100">
<mat-slider <gf-value
class="flex-grow-1" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[max]="data.rule.configuration.threshold.max" [locale]="data.locale"
[min]="data.rule.configuration.threshold.min" [precision]="2"
[step]="data.rule.configuration.threshold.step" [value]="data.rule.configuration.threshold.min"
> />
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" /> <mat-slider
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" /> class="flex-grow-1"
</mat-slider> [max]="data.rule.configuration.threshold.max"
<gf-value [min]="data.rule.configuration.threshold.min"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [step]="data.rule.configuration.threshold.step"
[locale]="data.locale" >
[precision]="2" <input formControlName="thresholdMin" matSliderStartThumb />
[value]="data.rule.configuration.threshold.max" <input formControlName="thresholdMax" matSliderEndThumb />
/> </mat-slider>
<gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale"
[precision]="2"
[value]="data.rule.configuration.threshold.max"
/>
</div>
</div> </div>
</div> } @else {
} @else { <div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin">
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMin"> <h6 class="d-flex mb-0">
<h6 class="d-flex mb-0"> <ng-container i18n>Threshold Min</ng-container>:
<ng-container i18n>Threshold Min</ng-container>: <gf-value
<gf-value class="ml-1"
class="ml-1" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="settingsForm.get('thresholdMin').value"
[value]="data.settings.thresholdMin" />
/> </h6>
</h6> <div class="align-items-center d-flex w-100">
<div class="align-items-center d-flex w-100"> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.min"
[value]="data.rule.configuration.threshold.min" />
/> <mat-slider
<mat-slider class="flex-grow-1"
class="flex-grow-1" name="thresholdMin"
name="thresholdMin" [max]="data.rule.configuration.threshold.max"
[max]="data.rule.configuration.threshold.max" [min]="data.rule.configuration.threshold.min"
[min]="data.rule.configuration.threshold.min" [step]="data.rule.configuration.threshold.step"
[step]="data.rule.configuration.threshold.step" >
> <input formControlName="thresholdMin" matSliderThumb />
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> </mat-slider>
</mat-slider> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.max"
[value]="data.rule.configuration.threshold.max" />
/> </div>
</div> </div>
</div> <div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax">
<div class="w-100" [class.d-none]="!data.rule.configuration.thresholdMax"> <h6 class="d-flex mb-0">
<h6 class="d-flex mb-0"> <ng-container i18n>Threshold Max</ng-container>:
<ng-container i18n>Threshold Max</ng-container>: <gf-value
<gf-value class="ml-1"
class="ml-1" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="settingsForm.get('thresholdMax').value"
[value]="data.settings.thresholdMax" />
/> </h6>
</h6> <div class="align-items-center d-flex w-100">
<div class="align-items-center d-flex w-100"> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.min"
[value]="data.rule.configuration.threshold.min" />
/> <mat-slider
<mat-slider class="flex-grow-1"
class="flex-grow-1" name="thresholdMax"
name="thresholdMax" [max]="data.rule.configuration.threshold.max"
[max]="data.rule.configuration.threshold.max" [min]="data.rule.configuration.threshold.min"
[min]="data.rule.configuration.threshold.min" [step]="data.rule.configuration.threshold.step"
[step]="data.rule.configuration.threshold.step" >
> <input formControlName="thresholdMax" matSliderThumb />
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> </mat-slider>
</mat-slider> <gf-value
<gf-value [isPercent]="data.rule.configuration.threshold.unit === '%'"
[isPercent]="data.rule.configuration.threshold.unit === '%'" [locale]="data.locale"
[locale]="data.locale" [precision]="2"
[precision]="2" [value]="data.rule.configuration.threshold.max"
[value]="data.rule.configuration.threshold.max" />
/> </div>
</div> </div>
</div> }
} </div>
</div>
<div align="end" mat-dialog-actions> <div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button> <button mat-button type="button" (click)="dialogRef.close()">
<button <ng-container i18n>Close</ng-container>
color="primary" </button>
mat-flat-button <button
(click)="dialogRef.close(data.settings)" color="primary"
> mat-flat-button
<ng-container i18n>Save</ng-container> type="submit"
</button> [disabled]="!settingsForm.dirty"
</div> >
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

4
apps/client/src/app/components/rules/rules.component.html

@ -3,9 +3,7 @@
<div class="col"> <div class="col">
@if (isLoading) { @if (isLoading) {
<gf-rule [isLoading]="true" /> <gf-rule [isLoading]="true" />
} } @else if (rules) {
@if (rules !== null && rules !== undefined) {
@for (rule of rules; track rule.key) { @for (rule of rules; track rule.key) {
<gf-rule <gf-rule
[categoryName]="categoryName" [categoryName]="categoryName"

4
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss

@ -4,4 +4,8 @@
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset; max-height: unset;
} }
.mat-mdc-dialog-title {
padding-right: 0.5rem !important;
}
} }

41
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -1,5 +1,6 @@
import { getSum } from '@ghostfolio/common/helper';
import { AdminUserResponse } from '@ghostfolio/common/interfaces'; import { AdminUserResponse } from '@ghostfolio/common/interfaces';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
@ -16,7 +17,11 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Subscription } from '@prisma/client';
import { Big } from 'big.js';
import { differenceInDays } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { ellipsisVertical } from 'ionicons/icons'; import { ellipsisVertical } from 'ionicons/icons';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
@ -35,7 +40,8 @@ import {
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatMenuModule MatMenuModule,
MatTableModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-user-detail-dialog', selector: 'gf-user-detail-dialog',
@ -43,18 +49,29 @@ import {
templateUrl: './user-detail-dialog.html' templateUrl: './user-detail-dialog.html'
}) })
export class GfUserDetailDialogComponent implements OnInit { export class GfUserDetailDialogComponent implements OnInit {
public baseCurrency: string;
public subscriptionsDataSource = new MatTableDataSource<Subscription>();
public subscriptionsDisplayedColumns = [
'createdAt',
'type',
'price',
'expiresAt'
];
public user: AdminUserResponse; public user: AdminUserResponse;
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
public dialogRef: MatDialogRef< public dialogRef: MatDialogRef<
GfUserDetailDialogComponent, GfUserDetailDialogComponent,
UserDetailDialogResult UserDetailDialogResult
> >
) { ) {
this.baseCurrency = this.dataService.fetchInfo().baseCurrency;
addIcons({ addIcons({
ellipsisVertical ellipsisVertical
}); });
@ -74,6 +91,8 @@ export class GfUserDetailDialogComponent implements OnInit {
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
this.subscriptionsDataSource.data = this.user.subscriptions ?? [];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -85,6 +104,24 @@ export class GfUserDetailDialogComponent implements OnInit {
}); });
} }
public getSum() {
return getSum(
this.subscriptionsDataSource.data.map(({ price }) => {
return new Big(price);
})
).toNumber();
}
public getType({ createdAt, expiresAt, price }: Subscription) {
if (price) {
return $localize`Paid`;
}
return differenceInDays(expiresAt, createdAt) <= 90
? $localize`Trial`
: $localize`Coupon`;
}
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

100
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -1,6 +1,6 @@
<div class="d-flex justify-content-end"> <div class="align-items-center d-flex justify-content-end" mat-dialog-title>
<button <button
class="mx-1 no-min-width px-2" class="ml-1 no-min-width px-2"
mat-button mat-button
type="button" type="button"
[matMenuTriggerFor]="userDetailActionsMenu" [matMenuTriggerFor]="userDetailActionsMenu"
@ -124,6 +124,102 @@
> >
</div> </div>
</div> </div>
@if (subscriptionsDataSource.data.length > 0) {
<div class="row">
<div class="col">
<h2 class="h6" i18n>Subscriptions</h2>
<div class="overflow-x-auto">
<table
class="gf-table w-100"
mat-table
[dataSource]="subscriptionsDataSource"
>
<ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.createdAt"
/>
</td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>
Total
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ getType(element) }}
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="price">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<ng-container i18n>Price</ng-container>
</th>
<td
*matCellDef="let element"
class="px-1 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="element.price"
/>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="getSum()"
/>
</td>
</ng-container>
<ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Expires</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.expiresAt"
/>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr
*matHeaderRowDef="subscriptionsDisplayedColumns"
mat-header-row
></tr>
<tr
*matRowDef="let row; columns: subscriptionsDisplayedColumns"
mat-row
></tr>
<tr
*matFooterRowDef="subscriptionsDisplayedColumns"
mat-footer-row
></tr>
</table>
</div>
</div>
</div>
}
} }
</div> </div>
</div> </div>

2
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -94,7 +94,7 @@
<ng-container i18n>or start a discussion at</ng-container> <ng-container i18n>or start a discussion at</ng-container>
<ng-container>&nbsp;</ng-container> <ng-container>&nbsp;</ng-container>
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
i18n-title i18n-title
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a

63
apps/client/src/app/pages/admin/admin-page.component.ts

@ -1,10 +1,19 @@
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
} from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { import {
GfPageTabsComponent, GfPageTabsComponent,
TabConfiguration TabConfiguration
} from '@ghostfolio/ui/page-tabs'; } from '@ghostfolio/ui/page-tabs';
import { Component, OnInit } from '@angular/core'; import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
flashOutline, flashOutline,
@ -21,10 +30,23 @@ import {
styleUrls: ['./admin-page.scss'], styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html' templateUrl: './admin-page.html'
}) })
export class AdminPageComponent implements OnInit { export class AdminPageComponent {
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
private user: User;
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.userService.stateChanged
.pipe(takeUntilDestroyed())
.subscribe((state) => {
this.user = state?.user;
this.initializeTabs();
});
addIcons({ addIcons({
flashOutline, flashOutline,
peopleOutline, peopleOutline,
@ -34,7 +56,12 @@ export class AdminPageComponent implements OnInit {
}); });
} }
public ngOnInit() { private initializeTabs() {
const hasPermissionToAccessBullBoard = hasPermission(
this.user?.permissions,
permissions.accessAdminControlBullBoard
);
this.tabs = [ this.tabs = [
{ {
iconName: 'reader-outline', iconName: 'reader-outline',
@ -51,11 +78,19 @@ export class AdminPageComponent implements OnInit {
label: internalRoutes.adminControl.subRoutes.marketData.title, label: internalRoutes.adminControl.subRoutes.marketData.title,
routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink
}, },
{ hasPermissionToAccessBullBoard
iconName: 'flash-outline', ? {
label: internalRoutes.adminControl.subRoutes.jobs.title, iconName: 'flash-outline',
routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink label: $localize`Job Queue`,
}, onClick: () => {
this.onOpenBullBoard();
}
}
: {
iconName: 'flash-outline',
label: internalRoutes.adminControl.subRoutes.jobs.title,
routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink
},
{ {
iconName: 'people-outline', iconName: 'people-outline',
label: internalRoutes.adminControl.subRoutes.users.title, label: internalRoutes.adminControl.subRoutes.users.title,
@ -63,4 +98,16 @@ export class AdminPageComponent implements OnInit {
} }
]; ];
} }
private onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
} }

2
apps/client/src/app/pages/faq/overview/faq-overview-page.html

@ -193,7 +193,7 @@
} }
or start a discussion at or start a discussion at
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a
>.</mat-card-content >.</mat-card-content

2
apps/client/src/app/pages/faq/saas/saas-page.html

@ -186,7 +186,7 @@
} }
or start a discussion at or start a discussion at
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a
>.</mat-card-content >.</mat-card-content

2
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -294,7 +294,7 @@
> >
or start a discussion at or start a discussion at
<a <a
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio/discussions"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub</a >GitHub</a
>.</mat-card-content >.</mat-card-content

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

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<div class="mb-3 row"> <div class="row">
<div class="col"> <div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1>
<gf-activities-table <gf-activities-table

5
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -1,6 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config'; import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
@ -120,7 +119,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures && this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag); hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.locale = this.data.user.settings.locale ?? defaultLocale; this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE;
this.mode = this.data.activity?.id ? 'update' : 'create'; this.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);

11
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -32,15 +32,18 @@
<mat-label i18n>Holding</mat-label> <mat-label i18n>Holding</mat-label>
<mat-select formControlName="assetProfileIdentifier"> <mat-select formControlName="assetProfileIdentifier">
<mat-select-trigger>{{ <mat-select-trigger>{{
assetProfileForm.get('assetProfileIdentifier')?.value?.name assetProfileForm.get('assetProfileIdentifier')?.value
?.assetProfile?.name
}}</mat-select-trigger> }}</mat-select-trigger>
@for (holding of holdings; track holding) { @for (holding of holdings; track holding) {
<mat-option <mat-option
class="line-height-1" class="line-height-1"
[value]="{ [value]="{
dataSource: holding.assetProfile.dataSource, assetProfile: {
name: holding.assetProfile.name, dataSource: holding.assetProfile.dataSource,
symbol: holding.assetProfile.symbol name: holding.assetProfile.name,
symbol: holding.assetProfile.symbol
}
}" }"
> >
<span <span

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save