Browse Source

Merge branch 'ghostfolio:main' into Overview_Graph

pull/5570/head
Batwam 7 hours ago
committed by GitHub
parent
commit
5057b1defe
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/build-code.yml
  2. 209
      CHANGELOG.md
  3. 6
      README.md
  4. 90
      apps/api/src/app/activities/activities.controller.ts
  5. 12
      apps/api/src/app/activities/activities.module.ts
  6. 73
      apps/api/src/app/activities/activities.service.ts
  7. 9
      apps/api/src/app/admin/admin.controller.ts
  8. 4
      apps/api/src/app/admin/admin.module.ts
  9. 18
      apps/api/src/app/admin/admin.service.ts
  10. 38
      apps/api/src/app/app.module.ts
  11. 29
      apps/api/src/app/auth-device/auth-device.controller.ts
  12. 4
      apps/api/src/app/endpoints/ai/ai.module.ts
  13. 7
      apps/api/src/app/endpoints/assets/assets.controller.ts
  14. 6
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  15. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  16. 7
      apps/api/src/app/endpoints/public/public.controller.ts
  17. 4
      apps/api/src/app/endpoints/public/public.module.ts
  18. 4
      apps/api/src/app/export/export.module.ts
  19. 11
      apps/api/src/app/export/export.service.ts
  20. 2
      apps/api/src/app/import/import.controller.ts
  21. 4
      apps/api/src/app/import/import.module.ts
  22. 154
      apps/api/src/app/import/import.service.ts
  23. 10
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  24. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  25. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  26. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  27. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  28. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  29. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  30. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  31. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  32. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  33. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  34. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  35. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  36. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  37. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  38. 13
      apps/api/src/app/portfolio/current-rate.service.ts
  39. 12
      apps/api/src/app/portfolio/portfolio.controller.ts
  40. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  41. 81
      apps/api/src/app/portfolio/portfolio.service.ts
  42. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  43. 2
      apps/api/src/app/subscription/subscription.service.ts
  44. 4
      apps/api/src/app/user/user.module.ts
  45. 20
      apps/api/src/app/user/user.service.ts
  46. 43
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  47. 1
      apps/api/src/assets/cryptocurrencies/custom.json
  48. 12
      apps/api/src/events/asset-profile-changed.event.ts
  49. 65
      apps/api/src/events/asset-profile-changed.listener.ts
  50. 4
      apps/api/src/events/events.module.ts
  51. 30
      apps/api/src/events/portfolio-changed.listener.ts
  52. 4
      apps/api/src/helper/object.helper.spec.ts
  53. 3
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  54. 6
      apps/api/src/main.ts
  55. 28
      apps/api/src/middlewares/bull-board-auth.middleware.ts
  56. 2
      apps/api/src/models/rule.ts
  57. 21
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  58. 2
      apps/api/src/services/configuration/configuration.service.ts
  59. 7
      apps/api/src/services/cryptocurrency/cryptocurrency.module.ts
  60. 38
      apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
  61. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  62. 12
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  63. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts
  64. 10
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  65. 155
      apps/api/src/services/data-provider/data-provider.service.ts
  66. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  67. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  68. 36
      apps/api/src/services/data-provider/manual/manual.service.ts
  69. 6
      apps/api/src/services/i18n/i18n.service.ts
  70. 2
      apps/api/src/services/interfaces/environment.interface.ts
  71. 14
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  72. 18
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  73. 6
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  74. 8
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  75. 4
      apps/client/proxy.conf.json
  76. 140
      apps/client/src/app/app.component.ts
  77. 11
      apps/client/src/app/components/access-table/access-table.component.html
  78. 76
      apps/client/src/app/components/access-table/access-table.component.ts
  79. 34
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  80. 55
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  81. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  82. 52
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  83. 61
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  84. 11
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  85. 18
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  86. 30
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  87. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  88. 40
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  89. 17
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts
  90. 27
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  91. 33
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  92. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  93. 40
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  94. 17
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  95. 33
      apps/client/src/app/components/admin-users/admin-users.component.ts
  96. 21
      apps/client/src/app/components/data-provider-status/data-provider-status.component.ts
  97. 8
      apps/client/src/app/components/footer/footer.component.ts
  98. 30
      apps/client/src/app/components/header/header.component.ts
  99. 38
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  100. 8
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

2
.github/workflows/build-code.yml

@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
node_version:
- 22
- 22.22.1
steps:
- name: Checkout code
uses: actions/checkout@v4

209
CHANGELOG.md

@ -5,6 +5,215 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added support for a copy-to-clipboard functionality in the value component
- Extended the holding detail dialog by adding a copy-to-clipboard button for the ISIN number (experimental)
- Extended the holding detail dialog by adding a copy-to-clipboard button for the symbol (experimental)
- Extended the user detail dialog of the admin control panel’s users section by adding a copy-to-clipboard button for the user id
### Changed
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Upgraded `countries-list` from version `3.2.2` to `3.3.0`
- Upgraded `ng-extract-i18n-merge` from `3.2.1` to `3.3.0`
- Upgraded `stripe` from version `20.3.0` to `20.4.1`
## 2.251.0 - 2026-03-24
### Added
- Added the quantity column to the holdings table of the portfolio holdings page
### Changed
- Hardened the endpoint `DELETE /api/v1/auth-device/:id` by improving the user validation
- Improved the allocations by ETF holding on the allocations page by refining the grouping of the same assets with diverging names (experimental)
- Improved the language localization for Polish (`pl`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `5.2.2` to `6.0.2`
### Fixed
- Fixed an issue by adding a missing guard in the public access for portfolio sharing
## 2.250.0 - 2026-03-17
### Added
- Added support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) on the portfolio activities page
### Changed
- Consolidated the sign-out logic within the user service to unify cookie, state and token clearance
- Improved the language localization for Polish (`pl`)
- Upgraded `@ionic/angular` from version `8.7.3` to `8.8.1`
- Upgraded `replace-in-file` from version `8.3.0` to `8.4.0`
- Upgraded `svgmap` from version `2.14.0` to `2.19.2`
- Pinned the _Node.js_ version in the _Build code_ _GitHub Action_ to ensure environment consistency for tests
### Fixed
- Fixed an issue with the detection of the thousand separator for the `de-CH` locale
- Fixed an issue in the _Storybook_ stories of the symbol autocomplete component caused by a circular dependency
## 2.249.0 - 2026-03-10
### Added
- Integrated _Bull Dashboard_ for a detailed jobs queue view in the admin control panel (experimental)
- Added a debounce to the `PortfolioChangedListener` and `AssetProfileChangedListener` to minimize redundant _Redis_ and database operations
### Changed
- Improved the _Storybook_ stories of the value component
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for German (`de`)
- Upgraded `class-validator` from version `0.14.3` to `0.15.1`
### Fixed
- Fixed false _Redis_ health check failures by using unique keys and increasing the timeout to 5s
## 2.248.0 - 2026-03-07
### Added
- Added support for column sorting to the data providers management of the admin control panel
### Changed
- Included asset profile data in the endpoint `GET api/v1/portfolio/holdings`
- Included asset profile data in the holdings of the public page
- Reused the value component in the platform management of the admin control panel
- Reused the value component in the tag management of the admin control panel
- Deprecated the `api/v1/order` endpoints in favor of the `api/v1/activities` endpoints
- Upgraded `jsonpath` from version `1.1.1` to `1.2.1`
### Fixed
- Fixed an issue in the _FIRE_ calculator to correctly calculate the projected total amount
## 2.247.0 - 2026-03-04
### Changed
- Upgraded `yahoo-finance2` from version `3.13.0` to `3.13.2`
## 2.246.0 - 2026-03-03
### Changed
- Removed the deprecated `committedFunds` from the summary of the portfolio details endpoint
- Upgraded `Nx` from version `22.4.5` to `22.5.3`
### Fixed
- Fixed an issue where the apply and reset filter buttons remained disabled in the assistant
## 2.245.0 - 2026-03-01
### Changed
- Excluded the scraper configuration from the import and export functionality
- Excluded the symbol mapping from the import and export functionality
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Spanish (`es`)
### Fixed
- Resolved the data source transformation in the errors of the performance endpoint
- Resolved the data source transformation in the export functionality
## 2.244.0 - 2026-02-28
### Changed
- Improved the usability of the asset profile details dialog in the admin control panel for currencies
- Removed the deprecated static portfolio analysis rule: _Fees_ (Fee Ratio)
- Refactored queries in the data provider service to use Prisma’s safe query methods
### Fixed
- Fixed an exception by adding a fallback for missing market price values on the _X-ray_ page
## 2.243.0 - 2026-02-23
### Changed
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.8` to `11.1.14`
### Fixed
- Fixed an issue when creating activities of type `FEE`, `INTEREST` or `LIABILITY`
## 2.242.0 - 2026-02-22
### Changed
- Changed the account field to optional in the create or update activity dialog
### Fixed
- Fixed a validation issue for valuables used in the create and import activity logic
- Fixed the page size for presets in the historical market data table of the admin control panel
## 2.241.0 - 2026-02-21
### Changed
- Improved the usability of the portfolio summary tab on the home page in the _Presenter View_
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
### Fixed
- Fixed an issue with `balanceInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `comment` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `dividendInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `interestInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `value` of the accounts in the value redaction interceptor for the impersonation mode
## 2.240.0 - 2026-02-18
### Added
- Added a _No Activities_ preset to the historical market data table of the admin control panel
- Added support for custom cryptocurrencies defined in the database
- Added support for the cryptocurrency _Sky_
### Changed
- Harmonized the validation for the create activity endpoint with the existing import activity logic
- Upgraded `marked` from version `17.0.1` to `17.0.2`
- Upgraded `ngx-markdown` from version `21.0.1` to `21.1.0`
## 2.239.0 - 2026-02-15
### Added
- Added a new static portfolio analysis rule based on the total investment volume: _Fees_ (Fee Ratio)
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page with information on derived currencies
### Changed
- Deprecated the existing static portfolio analysis rule: _Fees_ (Fee Ratio)
- Ignored nested ETFs when fetching top holdings for ETF and mutual fund assets from _Yahoo Finance_
- Improved the scraper configuration with more detailed error messages
- Improved the language localization for German (`de`)
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.1.0` to `13.2.2`
- Upgraded `cheerio` from version `1.0.0` to `1.2.0`
### Fixed
- Fixed the investment value by including currency effects in the portfolio summary tab on the home page
- Added the missing `valueInBaseCurrency` to the response of the import activities endpoint
## 2.238.0 - 2026-02-12
### Changed

6
README.md

@ -313,10 +313,12 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
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.
If you like to support this project, become a [**Sponsor**](https://github.com/sponsors/ghostfolio), get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## Sponsors
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing), become a [**Sponsor**](https://github.com/sponsors/ghostfolio) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
<br />
<div align="center">
<a href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool">
<img alt="TestMu AI Logo" height="45" src="https://assets.testmuai.com/resources/images/logos/logo.svg" />

90
apps/api/src/app/order/order.controller.ts → apps/api/src/app/activities/activities.controller.ts

@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
@ -36,27 +37,32 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel, Prisma } from '@prisma/client';
import { Order, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { OrderService } from './order.service';
import { ActivitiesService } from './activities.service';
@Controller('order')
export class OrderController {
@Controller([
'activities',
/** @deprecated */
'order'
])
export class ActivitiesController {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete()
@HasPermission(permissions.deleteOrder)
@HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders(
public async deleteActivities(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@ -71,29 +77,29 @@ export class OrderController {
filterByTags
});
return this.orderService.deleteOrders({
return this.activitiesService.deleteActivities({
filters,
userId: this.request.user.id
});
}
@Delete(':id')
@HasPermission(permissions.deleteOrder)
@HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({
public async deleteActivity(@Param('id') id: string): Promise<Order> {
const activity = await this.activitiesService.order({
id,
userId: this.request.user.id
});
if (!order) {
if (!activity) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.orderService.deleteOrder({
return this.activitiesService.deleteActivity({
id
});
}
@ -103,7 +109,7 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@ -120,7 +126,7 @@ export class OrderController {
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange(dateRange));
({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
}
const filters = this.apiService.buildFiltersFromQueryParams({
@ -135,7 +141,7 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
sortColumn,
@ -156,7 +162,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById(
public async getActivityById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<ActivityResponse> {
@ -164,7 +170,7 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
const { activities } = await this.activitiesService.getActivities({
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id,
@ -185,11 +191,34 @@ export class OrderController {
return activity;
}
@HasPermission(permissions.createOrder)
@HasPermission(permissions.createActivity)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
public async createActivity(@Body() data: CreateOrderDto): Promise<Order> {
try {
await this.dataProviderService.validateActivities({
activitiesDto: [
{
currency: data.currency,
dataSource: data.dataSource,
symbol: data.symbol,
type: data.type
}
],
maxActivitiesToImport: 1,
user: this.request.user
});
} catch (error) {
throw new HttpException(
{
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST
);
}
const currency = data.currency;
const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
@ -202,7 +231,7 @@ export class OrderController {
delete data.dataSource;
const order = await this.orderService.createOrder({
const activity = await this.activitiesService.createActivity({
...data,
date: parseISO(data.date),
SymbolProfile: {
@ -227,14 +256,14 @@ export class OrderController {
userId: this.request.user.id
});
if (dataSource && !order.isDraft) {
if (dataSource && !activity.isDraft) {
// Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols({
dataGatheringItems: [
{
dataSource,
date: order.date,
date: activity.date,
symbol: data.symbol
}
],
@ -242,19 +271,22 @@ export class OrderController {
});
}
return order;
return activity;
}
@HasPermission(permissions.updateOrder)
@HasPermission(permissions.updateActivity)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
const originalOrder = await this.orderService.order({
public async updateActivity(
@Param('id') id: string,
@Body() data: UpdateOrderDto
) {
const originalActivity = await this.activitiesService.order({
id
});
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
if (!originalActivity || originalActivity.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -277,7 +309,7 @@ export class OrderController {
delete data.dataSource;
return this.orderService.updateOrder({
return this.activitiesService.updateActivity({
data: {
...data,
date,

12
apps/api/src/app/order/order.module.ts → apps/api/src/app/activities/activities.module.ts

@ -15,12 +15,12 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
@Module({
controllers: [OrderController],
exports: [OrderService],
controllers: [ActivitiesController],
exports: [ActivitiesService],
imports: [
ApiModule,
CacheModule,
@ -35,6 +35,6 @@ import { OrderService } from './order.service';
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [AccountBalanceService, AccountService, OrderService]
providers: [AccountBalanceService, AccountService, ActivitiesService]
})
export class OrderModule {}
export class ActivitiesModule {}

73
apps/api/src/app/order/order.service.ts → apps/api/src/app/activities/activities.service.ts

@ -44,7 +44,7 @@ import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@Injectable()
export class OrderService {
export class ActivitiesService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@ -62,7 +62,7 @@ export class OrderService {
tags,
userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({
const activities = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
@ -73,7 +73,7 @@ export class OrderService {
});
await Promise.all(
orders.map(({ id }) =>
activities.map(({ id }) =>
this.prismaService.order.update({
data: {
tags: {
@ -96,7 +96,7 @@ export class OrderService {
);
}
public async createOrder(
public async createActivity(
data: Prisma.OrderCreateInput & {
accountId?: string;
assetClass?: AssetClass;
@ -201,7 +201,7 @@ export class OrderService {
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({
const activity = await this.prismaService.order.create({
data: {
...orderData,
account,
@ -235,56 +235,56 @@ export class OrderService {
this.eventEmitter.emit(
AssetProfileChangedEvent.getName(),
new AssetProfileChangedEvent({
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
symbol: order.SymbolProfile.symbol
currency: activity.SymbolProfile.currency,
dataSource: activity.SymbolProfile.dataSource,
symbol: activity.SymbolProfile.symbol
})
);
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
userId: activity.userId
})
);
return order;
return activity;
}
public async deleteOrder(
public async deleteActivity(
where: Prisma.OrderWhereUniqueInput
): Promise<Order> {
const order = await this.prismaService.order.delete({
const activity = await this.prismaService.order.delete({
where
});
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([
order.symbolProfileId
activity.symbolProfileId
]);
if (symbolProfile.activitiesCount === 0) {
await this.symbolProfileService.deleteById(order.symbolProfileId);
await this.symbolProfileService.deleteById(activity.symbolProfileId);
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
userId: activity.userId
})
);
return order;
return activity;
}
public async deleteOrders({
public async deleteActivities({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
const { activities } = await this.getActivities({
filters,
userId,
includeDrafts: true,
@ -324,7 +324,7 @@ export class OrderService {
}
/**
* Generates synthetic orders for cash holdings based on account balance history.
* Generates synthetic activities for cash holdings based on account balance history.
* Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow
* performance tracking based on exchange rate fluctuations.
*
@ -334,7 +334,7 @@ export class OrderService {
* @param userId - The ID of the user.
* @returns A response containing the list of synthetic cash activities.
*/
public async getCashOrders({
public async getCashActivities({
cashDetails,
filters = [],
userCurrency,
@ -448,7 +448,10 @@ export class OrderService {
};
}
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
public async getLatestActivity({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
@ -459,7 +462,7 @@ export class OrderService {
});
}
public async getOrders({
public async getActivities({
endDate,
filters,
includeDrafts = false,
@ -742,17 +745,17 @@ export class OrderService {
}
/**
* Retrieves all orders required for the portfolio calculator, including both standard asset orders
* and optional synthetic orders representing cash activities.
* Retrieves all activities required for the portfolio calculator, including both standard asset activities
* and optional synthetic activities representing cash activities.
*/
@LogPerformance
public async getOrdersForPortfolioCalculator({
public async getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId,
withCash = false
}: {
/** Optional filters to apply to the orders. */
/** Optional filters to apply to the activities. */
filters?: Filter[];
/** The base currency of the user. */
userCurrency: string;
@ -761,7 +764,7 @@ export class OrderService {
/** Whether to include cash activities in the result. */
withCash?: boolean;
}) {
const orders = await this.getOrders({
const activities = await this.getActivities({
filters,
userCurrency,
userId,
@ -775,18 +778,18 @@ export class OrderService {
currency: userCurrency
});
const cashOrders = await this.getCashOrders({
const cashActivities = await this.getCashActivities({
cashDetails,
filters,
userCurrency,
userId
});
orders.activities.push(...cashOrders.activities);
orders.count += cashOrders.count;
activities.activities.push(...cashActivities.activities);
activities.count += cashActivities.count;
}
return orders;
return activities;
}
public async getStatisticsByCurrency(
@ -817,7 +820,7 @@ export class OrderService {
});
}
public async updateOrder({
public async updateActivity({
data,
where
}: {
@ -882,7 +885,7 @@ export class OrderService {
data: { tags: { set: [] } }
});
const order = await this.prismaService.order.update({
const activity = await this.prismaService.order.update({
where,
data: {
...data,
@ -896,11 +899,11 @@ export class OrderService {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
userId: activity.userId
})
);
return order;
return activity;
}
private async orders(params: {

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

@ -172,7 +172,7 @@ export class AdminController {
let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange);
const { startDate } = getIntervalFromDateRange({ dateRange });
date = startDate;
}
@ -247,14 +247,17 @@ export class AdminController {
@Param('symbol') symbol: string
): Promise<{ price: number }> {
try {
const price = await this.manualService.test(data.scraperConfiguration);
const price = await this.manualService.test({
symbol,
scraperConfiguration: data.scraperConfiguration
});
if (price) {
return { price };
}
throw new Error(
`Could not parse the current market price for ${symbol} (${dataSource})`
`Could not parse the market price for ${symbol} (${dataSource})`
);
} catch (error) {
Logger.error(error, 'AdminController');

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

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -20,6 +20,7 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
@ -28,7 +29,6 @@ import { QueueModule } from './queue/queue.module';
DemoModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PrismaModule,
PropertyModule,
QueueModule,

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

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
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';
@ -55,12 +55,12 @@ import { groupBy } from 'lodash';
@Injectable()
export class AdminService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService
@ -225,6 +225,10 @@ export class AdminService {
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
}
const searchQuery = filters.find(({ type }) => {
@ -466,10 +470,12 @@ export class AdminService {
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
await this.activitiesService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([
@ -504,6 +510,8 @@ export class AdminService {
dataSource,
dateOfFirstActivity,
symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true
}
};
@ -790,7 +798,7 @@ export class AdminService {
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
await this.activitiesService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(

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

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
@ -10,10 +11,13 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@ -25,6 +29,7 @@ import { join } from 'node:path';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { ActivitiesModule } from './activities/activities.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module';
@ -48,7 +53,6 @@ import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
@ -62,6 +66,7 @@ import { UserModule } from './user/user.module';
AdminModule,
AccessModule,
AccountModule,
ActivitiesModule,
AiModule,
ApiKeysModule,
AssetModule,
@ -69,6 +74,29 @@ import { UserModule } from './user/user.module';
AuthDeviceModule,
AuthModule,
BenchmarksModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forRoot({
adapter: ExpressAdapter,
boardOptions: {
uiConfig: {
boardLogo: {
height: 0,
path: '',
width: 0
},
boardTitle: 'Job Queues',
favIcon: {
alternative: '/assets/favicon-32x32.png',
default: '/assets/favicon-32x32.png'
}
}
},
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
})
]
: []),
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -94,7 +122,6 @@ import { UserModule } from './user/user.module';
InfoModule,
LogoModule,
MarketDataModule,
OrderModule,
PlatformModule,
PlatformsModule,
PortfolioModule,
@ -105,7 +132,12 @@ import { UserModule } from './user/user.module';
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
exclude: [
`${BULL_BOARD_ROUTE}/*wildcard`,
'/.well-known/*wildcard',
'/api/*wildcard',
'/sitemap.xml'
],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: {
setHeaders: (res) => {

29
apps/api/src/app/auth-device/auth-device.controller.ts

@ -2,18 +2,43 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('auth-device')
export class AuthDeviceController {
public constructor(private readonly authDeviceService: AuthDeviceService) {}
public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
const originalAuthDevice = await this.authDeviceService.authDevice({
id,
userId: this.request.user.id
});
if (!originalAuthDevice) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id });
}
}

4
apps/api/src/app/endpoints/ai/ai.module.ts

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -29,6 +29,7 @@ import { AiService } from './ai.service';
@Module({
controllers: [AiController],
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
@ -37,7 +38,6 @@ import { AiService } from './ai.service';
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,

7
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -4,6 +4,7 @@ import { interpolate } from '@ghostfolio/common/helper';
import {
Controller,
Get,
OnModuleInit,
Param,
Res,
Version,
@ -14,12 +15,14 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@Controller('assets')
export class AssetsController {
export class AssetsController implements OnModuleInit {
private webManifest = '';
public constructor(
public readonly configurationService: ConfigurationService
) {
) {}
public onModuleInit() {
try {
this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'),

6
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -126,10 +126,10 @@ export class BenchmarksController {
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange(
const { endDate, startDate } = getIntervalFromDateRange({
dateRange,
new Date(startDateString)
);
startDate: new Date(startDateString)
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,

4
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -32,6 +32,7 @@ import { BenchmarksService } from './benchmarks.service';
@Module({
controllers: [BenchmarksController],
imports: [
ActivitiesModule,
ApiModule,
ConfigurationModule,
DataProviderModule,
@ -39,7 +40,6 @@ import { BenchmarksService } from './benchmarks.service';
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,

7
apps/api/src/app/endpoints/public/public.controller.ts

@ -1,5 +1,5 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
@ -28,9 +28,9 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class PublicController {
public constructor(
private readonly accessService: AccessService,
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@ -81,7 +81,7 @@ export class PublicController {
})
]);
const { activities } = await this.orderService.getOrders({
const { activities } = await this.activitiesService.getActivities({
sortColumn: 'date',
sortDirection: 'desc',
take: 10,
@ -167,6 +167,7 @@ export class PublicController {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,

4
apps/api/src/app/endpoints/public/public.module.ts

@ -1,7 +1,7 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -27,13 +27,13 @@ import { PublicController } from './public.controller';
controllers: [PublicController],
imports: [
AccessModule,
ActivitiesModule,
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
RedisCacheModule,

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

@ -1,5 +1,5 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -14,9 +14,9 @@ import { ExportService } from './export.service';
controllers: [ExportController],
imports: [
AccountModule,
ActivitiesModule,
ApiModule,
MarketDataModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],

11
apps/api/src/app/export/export.service.ts

@ -1,5 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -17,8 +17,8 @@ import { groupBy, uniqBy } from 'lodash';
export class ExportService {
public constructor(
private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService
) {}
@ -38,7 +38,7 @@ export class ExportService {
});
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({
let { activities } = await this.activitiesService.getActivities({
filters,
userId,
includeDrafts: true,
@ -182,10 +182,8 @@ export class ExportService {
isActive,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}) => {
return {
@ -204,11 +202,8 @@ export class ExportService {
isin,
marketData: marketDataByAssetProfile[id],
name,
scraperConfiguration:
scraperConfiguration as unknown as Prisma.JsonArray,
sectors: sectors as unknown as Prisma.JsonArray,
symbol,
symbolMapping,
url
};
}

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

@ -38,7 +38,7 @@ export class ImportController {
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder)
@HasPermission(permissions.createActivity)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import(

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

@ -1,6 +1,6 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -25,6 +25,7 @@ import { ImportService } from './import.service';
controllers: [ImportController],
imports: [
AccountModule,
ActivitiesModule,
ApiModule,
CacheModule,
ConfigurationModule,
@ -32,7 +33,6 @@ import { ImportService } from './import.service';
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PlatformModule,
PortfolioModule,
PrismaModule,

154
apps/api/src/app/import/import.service.ts

@ -1,10 +1,10 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -32,7 +32,7 @@ import {
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash';
@ -44,12 +44,12 @@ import { ImportDataDto } from './import-data.dto';
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService,
@ -91,7 +91,7 @@ export class ImportService {
userId,
withExcludedAccounts: true
}),
this.orderService.getOrders({
this.activitiesService.getActivities({
filters,
userCurrency,
userId,
@ -393,7 +393,7 @@ export class ImportService {
}
}
const assetProfiles = await this.validateActivities({
const assetProfiles = await this.dataProviderService.validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
@ -548,7 +548,7 @@ export class ImportService {
continue;
}
order = await this.orderService.createOrder({
order = await this.activitiesService.createActivity({
comment,
currency,
date,
@ -590,10 +590,18 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
);
activities.push({
...order,
error,
value,
valueInBaseCurrency: await valueInBaseCurrency,
// @ts-ignore
SymbolProfile: assetProfile
});
@ -637,7 +645,7 @@ export class ImportService {
userId: string;
}): Promise<Partial<Activity>[]> {
const { activities: existingActivities } =
await this.orderService.getOrders({
await this.activitiesService.getActivities({
userCurrency,
userId,
includeDrafts: true,
@ -719,132 +727,4 @@ export class ImportService {
return uniqueAccountIds.size === 1;
}
private async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.dataProviderService.getDataSources();
for (const [
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
if (!dataSources.includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
const dataProvider = this.dataProviderService.getDataProvider(
DataSource[dataSource]
);
if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);
}
}
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
// Skip asset profile validation for FEE, INTEREST, and LIABILITY
// as these activity types don't require asset profiles
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = {
currency,
dataSource,
symbol,
name: assetProfileInImport?.name
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
// Merge all fields of custom asset profiles into the validation object
Object.assign(assetProfile, {
assetClass: assetProfileInImport.assetClass,
assetSubClass: assetProfileInImport.assetSubClass,
comment: assetProfileInImport.comment,
countries: assetProfileInImport.countries,
currency: assetProfileInImport.currency,
cusip: assetProfileInImport.cusip,
dataSource: assetProfileInImport.dataSource,
figi: assetProfileInImport.figi,
figiComposite: assetProfileInImport.figiComposite,
figiShareClass: assetProfileInImport.figiShareClass,
holdings: assetProfileInImport.holdings,
isActive: assetProfileInImport.isActive,
isin: assetProfileInImport.isin,
name: assetProfileInImport.name,
scraperConfiguration: assetProfileInImport.scraperConfiguration,
sectors: assetProfileInImport.sectors,
symbol: assetProfileInImport.symbol,
symbolMapping: assetProfileInImport.symbolMapping,
url: assetProfileInImport.url
});
}
}
if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
}
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
}
}
return assetProfiles;
}
}

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

@ -158,10 +158,10 @@ export abstract class PortfolioCalculator {
this.redisCacheService = redisCacheService;
this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange(
'max',
subDays(dateOfFirstActivity, 1)
);
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: 'max',
startDate: subDays(dateOfFirstActivity, 1)
});
this.endDate = endOfDay(endDate);
this.startDate = startOfDay(startDate);
@ -885,7 +885,7 @@ export abstract class PortfolioCalculator {
// Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
getIntervalFromDateRange({ dateRange });
if (
!isBefore(dateRangeStart, startDate) &&

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -194,6 +194,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.07032490039195362,
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362,
netPerformanceWithCurrencyEffect: 33.4,
totalInvestment: 559,
totalInvestmentValueWithCurrencyEffect: 559
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -208,6 +208,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -192,6 +192,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -192,6 +192,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,
netPerformanceWithCurrencyEffect: 23.05,
totalInvestment: 273.2,
totalInvestmentValueWithCurrencyEffect: 273.2
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -210,6 +210,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 42.41983590271396609433,
netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854,
netPerformanceWithCurrencyEffect: 26516.208701400000064086,
totalInvestment: 318.542667299999967957,
totalInvestmentValueWithCurrencyEffect: 318.542667299999967957
})
);

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

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
@ -62,11 +62,11 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
describe('PortfolioCalculator', () => {
let accountBalanceService: AccountBalanceService;
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let orderService: OrderService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
@ -106,13 +106,13 @@ describe('PortfolioCalculator', () => {
);
currentRateService = new CurrentRateService(
dataProviderService,
null,
dataProviderService,
null,
null
);
orderService = new OrderService(
activitiesService = new ActivitiesService(
accountBalanceService,
accountService,
null,
@ -183,18 +183,17 @@ describe('PortfolioCalculator', () => {
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest.spyOn(orderService, 'getOrders').mockResolvedValue({
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [],
count: 0
});
const { activities } = await orderService.getOrdersForPortfolioCalculator(
{
const { activities } =
await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF',
userId: userDummyData.id,
withCash: true
}
);
});
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -128,6 +128,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -188,6 +188,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974,
totalInvestment: new Big('89.12').mul(0.8854).toNumber(),
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -174,6 +174,7 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
totalInvestment: 298.58,
totalInvestmentValueWithCurrencyEffect: 298.58
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -190,6 +190,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.12184460284330327256,
netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256,
netPerformanceWithCurrencyEffect: 17.68,
totalInvestment: 75.8,
totalInvestmentValueWithCurrencyEffect: 75.8
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -241,6 +241,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -162,6 +162,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 500000,
totalInvestmentValueWithCurrencyEffect: 500000
})
);

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

@ -860,7 +860,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
return format(date, 'yyyy');
})
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;

2
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -114,9 +114,9 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService(
null,
dataProviderService,
marketDataService,
null,
null
);
});

13
apps/api/src/app/portfolio/current-rate.service.ts

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -24,9 +24,9 @@ export class CurrentRateService {
private static readonly MARKET_DATA_PAGE_SIZE = 50000;
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -129,10 +129,11 @@ export class CurrentRateService {
if (!value) {
// Fallback to unit price of latest activity
const latestActivity = await this.orderService.getLatestOrder({
dataSource,
symbol
});
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
value = {
dataSource,

12
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
@ -63,10 +63,10 @@ import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio')
export class PortfolioController {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -187,7 +187,6 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentNetWorth',
'currentValueInBaseCurrency',
'dividendInBaseCurrency',
@ -206,6 +205,7 @@ export class PortfolioController {
'netPerformanceWithCurrencyEffect',
'totalBuy',
'totalInvestment',
'totalInvestmentValueWithCurrencyEffect',
'totalSell',
'totalValueInBaseCurrency'
]);
@ -320,9 +320,9 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { activities } = await this.orderService.getOrders({
const { activities } = await this.activitiesService.getActivities({
endDate,
filters,
startDate,
@ -639,7 +639,7 @@ export class PortfolioController {
return report;
}
@HasPermission(permissions.updateOrder)
@HasPermission(permissions.updateActivity)
@Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -1,7 +1,7 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module';
@ -34,6 +34,7 @@ import { RulesService } from './rules.service';
exports: [PortfolioService],
imports: [
AccessModule,
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
@ -43,7 +44,6 @@ import { RulesService } from './rules.service';
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PerformanceLoggingModule,
PortfolioSnapshotQueueModule,
PrismaModule,

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

@ -1,7 +1,7 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -13,7 +13,7 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
@ -105,13 +105,13 @@ export class PortfolioService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService,
@ -403,10 +403,10 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
@ -490,7 +490,7 @@ export class PortfolioService {
);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
@ -623,6 +623,28 @@ export class PortfolioService {
? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass,
assetProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
name: assetProfile.name,
sectors: assetProfile.sectors,
symbol: assetProfile.symbol,
url: assetProfile.url
},
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
@ -758,7 +780,7 @@ export class PortfolioService {
const userCurrency = this.getUserCurrency(user);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency,
userId
});
@ -988,7 +1010,7 @@ export class PortfolioService {
userId,
userCurrency
}),
this.orderService.getOrdersForPortfolioCalculator({
this.activitiesService.getActivitiesForPortfolioCalculator({
filters,
userCurrency,
userId
@ -1007,7 +1029,8 @@ export class PortfolioService {
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
}
};
}
@ -1024,7 +1047,7 @@ export class PortfolioService {
const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
@ -1038,6 +1061,7 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect,
netWorth,
totalInvestment,
totalInvestmentValueWithCurrencyEffect,
valueWithCurrencyEffect
} = chart?.at(-1) ?? {
netPerformance: 0,
@ -1058,6 +1082,7 @@ export class PortfolioService {
netPerformance,
netPerformanceWithCurrencyEffect,
totalInvestment,
totalInvestmentValueWithCurrencyEffect,
currentNetWorth: netWorth,
currentValueInBaseCurrency: valueWithCurrencyEffect,
netPerformancePercentage: netPerformanceInPercentage,
@ -1306,11 +1331,11 @@ export class PortfolioService {
}),
rules: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
new FeeRatioTotalInvestmentVolume(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.totalBuy + summary.totalSell,
summary.fees
)
],
@ -1346,7 +1371,12 @@ export class PortfolioService {
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
await this.activitiesService.assignTags({
dataSource,
symbol,
tags,
userId
});
}
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
@ -1669,6 +1699,17 @@ export class PortfolioService {
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
assetProfile: {
currency,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
holdings: [],
name: currency,
sectors: [],
symbol: currency
},
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
@ -1838,7 +1879,7 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const { activities } = await this.orderService.getOrders({
const { activities } = await this.activitiesService.getActivities({
userCurrency,
userId,
withExcludedAccountsAndActivities: true
@ -1860,8 +1901,11 @@ export class PortfolioService {
}
}
const { currentValueInBaseCurrency, totalInvestment } =
await portfolioCalculator.getSnapshot();
const {
currentValueInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect
} = await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({
impersonationId,
@ -1908,8 +1952,6 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency,
activities: excludedActivities,
@ -1973,7 +2015,6 @@ export class PortfolioService {
activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
@ -2004,6 +2045,8 @@ export class PortfolioService {
interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentWithCurrencyEffect.toNumber(),
totalValueInBaseCurrency: netWorth
};
}

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

@ -6,7 +6,7 @@ import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import Keyv from 'keyv';
import ms from 'ms';
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
@Injectable()
export class RedisCacheService {
@ -75,13 +75,16 @@ export class RedisCacheService {
}
public async isHealthy() {
const testKey = '__health_check__';
const HEALTH_CHECK_TIMEOUT = ms('5 seconds');
const testKey = `__health_check__${randomUUID().replace(/-/g, '')}`;
const testValue = Date.now().toString();
try {
await Promise.race([
(async () => {
await this.set(testKey, testValue, ms('1 second'));
await this.set(testKey, testValue, HEALTH_CHECK_TIMEOUT);
const result = await this.get(testKey);
if (result !== testValue) {
@ -91,7 +94,7 @@ export class RedisCacheService {
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds')
HEALTH_CHECK_TIMEOUT
)
)
]);

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

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2026-01-28.clover'
apiVersion: '2026-02-25.clover'
}
);
}

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

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -18,6 +18,7 @@ import { UserService } from './user.service';
controllers: [UserController],
exports: [UserService],
imports: [
ActivitiesModule,
ConfigurationModule,
I18nModule,
ImpersonationModule,
@ -25,7 +26,6 @@ import { UserService } from './user.service';
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
OrderModule,
PrismaModule,
PropertyModule,
RedactValuesInResponseModule,

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

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
@ -12,7 +12,7 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
@ -55,10 +55,10 @@ import { createHmac } from 'node:crypto';
@Injectable()
export class UserService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
@ -376,7 +376,7 @@ export class UserService {
undefined,
undefined
).getSettings(user.settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
FeeRatioTotalInvestmentVolume: new FeeRatioTotalInvestmentVolume(
undefined,
undefined,
undefined,
@ -530,8 +530,14 @@ export class UserService {
}
}
if (!environment.production && hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.impersonateAllUsers);
if (hasRole(user, Role.ADMIN)) {
if (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) {
currentPermissions.push(permissions.accessAdminControlBullBoard);
}
if (!environment.production) {
currentPermissions.push(permissions.impersonateAllUsers);
}
}
user.accounts = user.accounts.sort((a, b) => {
@ -643,7 +649,7 @@ export class UserService {
} catch {}
try {
await this.orderService.deleteOrders({
await this.activitiesService.deleteActivities({
userId: where.id
});
} catch {}

43
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

@ -421,6 +421,7 @@
"AGS": "Aegis",
"AGT": "Alaya Governance Token",
"AGURI": "Aguri-Chan",
"AGUSTO": "Agusto",
"AGV": "Astra Guild Ventures",
"AGVC": "AgaveCoin",
"AGVE": "Agave",
@ -662,6 +663,7 @@
"ALN": "Aluna",
"ALNV1": "Aluna v1",
"ALOHA": "Aloha",
"ALOKA": "ALOKA",
"ALON": "Alon",
"ALOR": "The Algorix",
"ALOT": "Dexalot",
@ -708,6 +710,7 @@
"AMADEUS": "AMADEUS",
"AMAL": "AMAL",
"AMAPT": "Amnis Finance",
"AMARA": "AMARA",
"AMATEN": "Amaten",
"AMATO": "AMATO",
"AMAZINGTEAM": "AmazingTeamDAO",
@ -1344,6 +1347,7 @@
"AZIT": "Azit",
"AZNX": "AstraZeneca xStock",
"AZR": "Azure",
"AZTEC": "AZTEC",
"AZU": "Azultec",
"AZUKI": "Azuki",
"AZUKI2": "AZUKI 2.0",
@ -1373,6 +1377,7 @@
"BABI": "Babylons",
"BABL": "Babylon Finance",
"BABY": "Babylon",
"BABY4": "Baby 4",
"BABYANDY": "Baby Andy",
"BABYASTER": "Baby Aster",
"BABYB": "Baby Bali",
@ -2342,6 +2347,7 @@
"BNPL": "BNPL Pay",
"BNR": "BiNeuro",
"BNRTX": "BnrtxCoin",
"BNRY": "Binary Coin",
"BNS": "BNS token",
"BNSAI": "bonsAI Network",
"BNSD": "BNSD Finance",
@ -2526,9 +2532,10 @@
"BOSSCOQ": "THE COQFATHER",
"BOST": "BoostCoin",
"BOSU": "Bosu Inu",
"BOT": "Bot Planet",
"BOT": "HyperBot",
"BOTC": "BotChain",
"BOTIFY": "BOTIFY",
"BOTPLANET": "Bot Planet",
"BOTS": "ArkDAO",
"BOTTO": "Botto",
"BOTX": "BOTXCOIN",
@ -3201,6 +3208,7 @@
"CATCO": "CatCoin",
"CATCOIN": "CatCoin",
"CATCOINETH": "Catcoin",
"CATCOINIO": "Catcoin",
"CATCOINOFSOL": "Cat Coin",
"CATCOINV2": "CatCoin Cash",
"CATDOG": "Cat-Dog",
@ -3583,6 +3591,7 @@
"CIC": "Crazy Internet Coin",
"CICHAIN": "CIChain",
"CIF": "Crypto Improvement Fund",
"CIFRON": "Cipher Mining (Ondo Tokenized)",
"CIG": "cig",
"CIM": "COINCOME",
"CIN": "CinderCoin",
@ -3718,6 +3727,7 @@
"CMPT": "Spatial Computing",
"CMPV2": "Caduceus Protocol",
"CMQ": "Communique",
"CMR": "U.S Critical Mineral Reserve",
"CMS": "COMSA",
"CMSN": "The Commission",
"CMT": "CyberMiles",
@ -4630,6 +4640,7 @@
"DEFIL": "DeFIL",
"DEFILAB": "Defi",
"DEFISCALE": "DeFiScale",
"DEFISSI": "DEFI.ssi",
"DEFIT": "Digital Fitness",
"DEFLA": "Defla",
"DEFLCT": "Deflect",
@ -6323,7 +6334,7 @@
"FIFTY": "FIFTYONEFIFTY",
"FIG": "FlowCom",
"FIGH": "FIGHT FIGHT FIGHT",
"FIGHT": "Fight to MAGA",
"FIGHT2MAGA": "Fight to MAGA",
"FIGHTMAGA": "FIGHT MAGA",
"FIGHTPEPE": "FIGHT PEPE",
"FIGHTRUMP": "FIGHT TRUMP",
@ -8039,6 +8050,7 @@
"HONOR": "HonorLand",
"HONX": "Honeywell xStock",
"HOODOG": "Hoodog",
"HOODON": "Robinhood Markets (Ondo Tokenized)",
"HOODRAT": "Hoodrat Coin",
"HOODX": "Robinhood xStock",
"HOOF": "Metaderby Hoof",
@ -8395,6 +8407,7 @@
"IMS": "Independent Money System",
"IMST": "Imsmart",
"IMT": "Immortal Token",
"IMU": "Immunefi",
"IMUSIFY": "imusify",
"IMVR": "ImmVRse",
"IMX": "Immutable X",
@ -8750,6 +8763,7 @@
"JFIVE": "Jonny Five",
"JFOX": "JuniperFox AI",
"JFP": "JUSTICE FOR PEANUT",
"JGGL": "JGGL Token",
"JGLP": "Jones GLP",
"JGN": "Juggernaut",
"JHH": "Jen-Hsun Huang",
@ -9891,7 +9905,7 @@
"LRN": "Loopring [NEO]",
"LRT": "LandRocker",
"LSC": "LS Coin",
"LSD": "Pontem Liquidswap",
"LSD": "LSD",
"LSDOGE": "LSDoge",
"LSETH": "Liquid Staked ETH",
"LSHARE": "LSHARE",
@ -10167,8 +10181,7 @@
"MANUSAI": "Manus AI Agent",
"MANYU": "Manyu",
"MANYUDOG": "MANYU",
"MAO": "MAO",
"MAOMEME": "Mao",
"MAO": "Mao",
"MAOW": "MAOW",
"MAP": "MAP Protocol",
"MAPC": "MapCoin",
@ -10631,6 +10644,7 @@
"MICRO": "Micro GPT",
"MICRODOGE": "MicroDoge",
"MICROMINES": "Micromines",
"MICROVISION": "MicroVisionChain",
"MIDAI": "Midway AI",
"MIDAS": "Midas",
"MIDASDOLLAR": "Midas Dollar Share",
@ -13146,6 +13160,7 @@
"PONKE": "Ponke",
"PONKEBNB": "Ponke BNB",
"PONKEI": "Chinese Ponkei the Original",
"PONTEM": "Pontem Liquidswap",
"PONYO": "Ponyo Impact",
"PONZI": "Ponzi",
"PONZIO": "Ponzio The Cat",
@ -13573,6 +13588,7 @@
"QNX": "QueenDex Coin",
"QOBI": "Qobit",
"QOM": "Shiba Predator",
"QONE": "QONE",
"QOOB": "QOOBER",
"QORA": "QoraCoin",
"QORPO": "QORPO WORLD",
@ -15153,6 +15169,7 @@
"SNAP": "SnapEx",
"SNAPCAT": "Snapcat",
"SNAPKERO": "SNAP",
"SNAPON": "Snap (Ondo Tokenized)",
"SNB": "SynchroBitcoin",
"SNC": "SunContract",
"SNCT": "SnakeCity",
@ -15380,7 +15397,7 @@
"SP8DE": "Sp8de",
"SPA": "Sperax",
"SPAC": "SPACE DOGE",
"SPACE": "MicroVisionChain",
"SPACE": "Spacecoin",
"SPACECOIN": "SpaceCoin",
"SPACED": "SPACE DRAGON",
"SPACEHAMSTER": "Space Hamster",
@ -15868,6 +15885,7 @@
"SUPERCYCLE": "Crypto SuperCycle",
"SUPERDAPP": "SuperDapp",
"SUPERF": "SUPER FLOKI",
"SUPERFL": "Superfluid",
"SUPERGROK": "SuperGrok",
"SUPEROETHB": "Super OETH",
"SUPERT": "Super Trump",
@ -16790,6 +16808,7 @@
"TSLAON": "Tesla (Ondo Tokenized)",
"TSLAX": "Tesla xStock",
"TSLT": "Tamkin",
"TSMON": "Taiwan Semiconductor Manufacturing (Ondo Tokenized)",
"TSN": "Tsunami Exchange Token",
"TSO": "Thesirion",
"TSOTCHKE": "tsotchke",
@ -17181,8 +17200,10 @@
"USDL": "Lift Dollar",
"USDM": "USDM",
"USDMA": "USD mars",
"USDN": "Neutral AI",
"USDN": "Ultimate Synthetic Delta Neutral",
"USDNEUTRAL": "Neutral AI",
"USDO": "USD Open Dollar",
"USDON": "U.S. Dollar Tokenized Currency (Ondo)",
"USDP": "Pax Dollar",
"USDPLUS": "Overnight.fi USD+",
"USDQ": "Quantoz USDQ",
@ -17456,6 +17477,7 @@
"VIDZ": "PureVidz",
"VIEW": "Viewly",
"VIG": "TheVig",
"VIGI": "Vigi",
"VIK": "VIKTAMA",
"VIKITA": "VIKITA",
"VIKKY": "VikkyToken",
@ -17513,6 +17535,7 @@
"VLC": "Volcano Uni",
"VLDY": "Validity",
"VLK": "Vulkania",
"VLR": "Velora",
"VLS": "Veles",
"VLT": "Veltor",
"VLTC": "Venus LTC",
@ -17733,6 +17756,7 @@
"WANUSDT": "wanUSDT",
"WAP": "Wet Ass Pussy",
"WAR": "WAR",
"WARD": "Warden",
"WARP": "WarpCoin",
"WARPED": "Warped Games",
"WARPIE": "Warpie",
@ -18494,6 +18518,7 @@
"XP": "Xphere",
"XPA": "XPA",
"XPARTY": "X Party",
"XPASS": "XPASS Token",
"XPAT": "Bitnation Pangea",
"XPAY": "Wallet Pay",
"XPB": "Pebble Coin",
@ -18869,8 +18894,7 @@
"ZEBU": "ZEBU",
"ZEC": "ZCash",
"ZECD": "ZCashDarkCoin",
"ZED": "ZED Token",
"ZEDCOIN": "ZedCoin",
"ZED": "ZedCoins",
"ZEDD": "ZedDex",
"ZEDTOKEN": "Zed Token",
"ZEDX": "ZEDX Сoin",
@ -19108,6 +19132,7 @@
"币安人生": "币安人生",
"恶俗企鹅": "恶俗企鹅",
"我踏马来了": "我踏马来了",
"狗屎": "狗屎",
"老子": "老子",
"雪球": "雪球",
"黑马": "黑马"

1
apps/api/src/assets/cryptocurrencies/custom.json

@ -4,6 +4,7 @@
"LUNA1": "Terra",
"LUNA2": "Terra",
"SGB1": "Songbird",
"SKY33038": "Sky",
"SMURFCAT": "Real Smurf Cat",
"TON11419": "Toncoin",
"UNI1": "Uniswap",

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

@ -8,4 +8,16 @@ export class AssetProfileChangedEvent {
public static getName(): string {
return 'assetProfile.changed';
}
public getCurrency() {
return this.data.currency;
}
public getDataSource() {
return this.data.dataSource;
}
public getSymbol() {
return this.data.symbol;
}
}

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

@ -1,29 +1,74 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable()
export class AssetProfileChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService
private readonly exchangeRateDataService: ExchangeRateDataService
) {}
@OnEvent(AssetProfileChangedEvent.getName())
public async handleAssetProfileChanged(event: AssetProfileChangedEvent) {
public handleAssetProfileChanged(event: AssetProfileChangedEvent) {
const currency = event.getCurrency();
const dataSource = event.getDataSource();
const symbol = event.getSymbol();
const key = getAssetProfileIdentifier({
dataSource,
symbol
});
const existingTimer = this.debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.debounceTimers.set(
key,
setTimeout(() => {
this.debounceTimers.delete(key);
void this.processAssetProfileChanged({
currency,
dataSource,
symbol
});
}, AssetProfileChangedListener.DEBOUNCE_DELAY)
);
}
private async processAssetProfileChanged({
currency,
dataSource,
symbol
}: {
currency: string;
dataSource: DataSource;
symbol: string;
}) {
Logger.log(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`,
`Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener'
);
@ -31,16 +76,16 @@ export class AssetProfileChangedListener {
this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false ||
event.data.currency === DEFAULT_CURRENCY
currency === DEFAULT_CURRENCY
) {
return;
}
const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) {
if (!existingCurrencies.includes(currency)) {
Logger.log(
`New currency ${event.data.currency} has been detected`,
`New currency ${currency} has been detected`,
'AssetProfileChangedListener'
);
@ -48,13 +93,13 @@ export class AssetProfileChangedListener {
}
const { dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(event.data.currency);
await this.activitiesService.getStatisticsByCurrency(currency);
if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
date: dateOfFirstActivity,
symbol: `${DEFAULT_CURRENCY}${event.data.currency}`
symbol: `${DEFAULT_CURRENCY}${currency}`
});
}
}

4
apps/api/src/events/events.module.ts

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -12,11 +12,11 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [
ActivitiesModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
OrderModule,
RedisCacheModule
],
providers: [AssetProfileChangedListener, PortfolioChangedListener]

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

@ -2,22 +2,44 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import ms from 'ms';
import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable()
export class PortfolioChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
const userId = event.getUserId();
const existingTimer = this.debounceTimers.get(userId);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.debounceTimers.set(
userId,
setTimeout(() => {
this.debounceTimers.delete(userId);
void this.processPortfolioChanged({ userId });
}, PortfolioChangedListener.DEBOUNCE_DELAY)
);
}
private async processPortfolioChanged({ userId }: { userId: string }) {
Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`,
`Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener'
);
this.redisCacheService.removePortfolioSnapshotsByUserId({
userId: event.getUserId()
});
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
}
}

4
apps/api/src/helper/object.helper.spec.ts

@ -1540,7 +1540,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null,
totalBuy: null,
totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null,
dividendInBaseCurrency: null,
emergencyFund: null,
@ -1554,6 +1553,7 @@ describe('redactAttributes', () => {
items: null,
liabilities: null,
totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null,
currentNetWorth: null
}
@ -3016,7 +3016,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null,
totalBuy: null,
totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null,
dividendInBaseCurrency: null,
emergencyFund: null,
@ -3030,6 +3029,7 @@ describe('redactAttributes', () => {
items: null,
liabilities: null,
totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null,
currentNetWorth: null
}

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

@ -62,10 +62,13 @@ export class TransformDataSourceInResponseInterceptor<
valueMap,
object: data,
paths: [
'activities[*].dataSource',
'activities[*].SymbolProfile.dataSource',
'benchmarks[*].dataSource',
'errors[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',
'fearAndGreedIndex.STOCKS.dataSource',
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
'SymbolProfile.dataSource',

6
apps/api/src/main.ts

@ -1,4 +1,5 @@
import {
BULL_BOARD_ROUTE,
DEFAULT_HOST,
DEFAULT_PORT,
STORYBOOK_PATH,
@ -14,6 +15,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
@ -46,6 +48,7 @@ async function bootstrap() {
});
app.setGlobalPrefix('api', {
exclude: [
`${BULL_BOARD_ROUTE.substring(1)}{/*wildcard}`,
'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
// Exclude language-specific routes with an optional wildcard
@ -53,6 +56,7 @@ async function bootstrap() {
})
]
});
app.useGlobalPipes(
new ValidationPipe({
forbidNonWhitelisted: true,
@ -64,6 +68,8 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities
app.useBodyParser('json', { limit: '10mb' });
app.use(cookieParser());
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) {

28
apps/api/src/middlewares/bull-board-auth.middleware.ts

@ -0,0 +1,28 @@
import { BULL_BOARD_COOKIE_NAME } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import passport from 'passport';
@Injectable()
export class BullBoardAuthMiddleware implements NestMiddleware {
public use(req: Request, res: Response, next: NextFunction) {
const token = req.cookies?.[BULL_BOARD_COOKIE_NAME];
if (token) {
req.headers.authorization = `Bearer ${token}`;
}
passport.authenticate('jwt', { session: false }, (error, user) => {
if (
error ||
!hasPermission(user?.permissions, permissions.accessAdminControl)
) {
next(new ForbiddenException());
} else {
next();
}
})(req, res, next);
}
}

2
apps/api/src/models/rule.ts

@ -57,7 +57,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
previousValue +
this.exchangeRateDataService.toCurrency(
new Big(currentValue.quantity)
.mul(currentValue.marketPrice)
.mul(currentValue.marketPrice ?? 0)
.toNumber(),
currentValue.currency,
baseCurrency

21
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts → apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts

@ -3,35 +3,36 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class FeeRatioInitialInvestment extends Rule<Settings> {
export class FeeRatioTotalInvestmentVolume extends Rule<Settings> {
private fees: number;
private totalInvestment: number;
private totalInvestmentVolumeInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
totalInvestment: number,
totalInvestmentVolumeInBaseCurrency: number,
fees: number
) {
super(exchangeRateDataService, {
languageCode,
key: FeeRatioInitialInvestment.name
key: FeeRatioTotalInvestmentVolume.name
});
this.fees = fees;
this.totalInvestment = totalInvestment;
this.totalInvestmentVolumeInBaseCurrency =
totalInvestmentVolumeInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const feeRatio = this.totalInvestment
? this.fees / this.totalInvestment
const feeRatio = this.totalInvestmentVolumeInBaseCurrency
? this.fees / this.totalInvestmentVolumeInBaseCurrency
: 0;
if (feeRatio > ruleSettings.thresholdMax) {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.false',
id: 'rule.feeRatioTotalInvestmentVolume.false',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
@ -44,7 +45,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.true',
id: 'rule.feeRatioTotalInvestmentVolume.true',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3),
@ -76,7 +77,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getName() {
return this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment',
id: 'rule.feeRatioTotalInvestmentVolume',
languageCode: this.getLanguageCode()
});
}

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

@ -30,6 +30,7 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
@ -43,6 +44,7 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_BULL_BOARD: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),

7
apps/api/src/services/cryptocurrency/cryptocurrency.module.ts

@ -1,9 +1,12 @@
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { CryptocurrencyService } from './cryptocurrency.service';
@Module({
providers: [CryptocurrencyService],
exports: [CryptocurrencyService]
exports: [CryptocurrencyService],
imports: [PropertyModule],
providers: [CryptocurrencyService]
})
export class CryptocurrencyModule {}

38
apps/api/src/services/cryptocurrency/cryptocurrency.service.ts

@ -1,31 +1,39 @@
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
PROPERTY_CUSTOM_CRYPTOCURRENCIES
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
@Injectable()
export class CryptocurrencyService {
export class CryptocurrencyService implements OnModuleInit {
private combinedCryptocurrencies: string[];
public constructor(private readonly propertyService: PropertyService) {}
public async onModuleInit() {
const customCryptocurrenciesFromDatabase =
await this.propertyService.getByKey<Record<string, string>>(
PROPERTY_CUSTOM_CRYPTOCURRENCIES
);
this.combinedCryptocurrencies = [
...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies),
...Object.keys(customCryptocurrenciesFromDatabase ?? {})
];
}
public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return (
aSymbol.endsWith(DEFAULT_CURRENCY) &&
this.getCryptocurrencies().includes(cryptocurrencySymbol)
this.combinedCryptocurrencies.includes(cryptocurrencySymbol)
);
}
private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [
...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies)
];
}
return this.combinedCryptocurrencies;
}
}

10
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -16,7 +16,7 @@ import {
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns';
@ -24,12 +24,16 @@ import { format, isAfter, isBefore, parse } from 'date-fns';
import { AlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable()
export class AlphaVantageService implements DataProviderInterface {
export class AlphaVantageService
implements DataProviderInterface, OnModuleInit
{
public alphaVantage;
public constructor(
private readonly configurationService: ConfigurationService
) {
) {}
public onModuleInit() {
this.alphaVantage = Alphavantage({
key: this.configurationService.get('API_KEY_ALPHA_VANTAGE')
});

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

@ -17,7 +17,7 @@ import {
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -27,13 +27,15 @@ import {
import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable()
export class CoinGeckoService implements DataProviderInterface {
private readonly apiUrl: string;
private readonly headers: HeadersInit = {};
export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private apiUrl: string;
private headers: HeadersInit = {};
public constructor(
private readonly configurationService: ConfigurationService
) {
) {}
public onModuleInit() {
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO');

2
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts

@ -29,7 +29,7 @@ describe('YahooFinanceDataEnhancerService', () => {
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => {
cryptocurrencyService = new CryptocurrencyService();
cryptocurrencyService = new CryptocurrencyService(null);
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
cryptocurrencyService

10
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

@ -207,14 +207,16 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) {
response.holdings =
assetProfile.topHoldings?.holdings?.map(
({ holdingName, holdingPercent }) => {
assetProfile.topHoldings?.holdings
?.filter(({ holdingName }) => {
return !holdingName?.includes('ETF');
})
?.map(({ holdingName, holdingPercent }) => {
return {
name: this.formatName({ longName: holdingName }),
weight: holdingPercent
};
}
) ?? [];
}) ?? [];
response.sectors = (
assetProfile.topHoldings?.sectorWeightings ?? []

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

@ -1,3 +1,4 @@
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -10,8 +11,10 @@ import {
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
getStartOfUtcDate,
isCurrency,
@ -27,7 +30,7 @@ import {
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
@ -185,6 +188,125 @@ export class DataProviderService implements OnModuleInit {
return dataSources.sort();
}
public async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Pick<
Partial<CreateOrderDto>,
'currency' | 'dataSource' | 'symbol' | 'type'
>[];
assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.getDataSources();
for (const [
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
const activityPath =
maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`;
if (!dataSources.includes(dataSource)) {
throw new Error(
`${activityPath}.dataSource ("${dataSource}") is not valid`
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error(
`${activityPath}.dataSource ("${dataSource}") is not valid`
);
}
}
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
if (!assetProfiles[assetProfileIdentifier]) {
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
['FEE', 'INTEREST', 'LIABILITY'].includes(type)
) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => {
return (
assetProfile.dataSource === dataSource &&
assetProfile.symbol === symbol
);
}
);
assetProfiles[assetProfileIdentifier] = {
currency,
dataSource,
symbol,
name: assetProfileInImport?.name ?? symbol
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.getAssetProfiles([
{
dataSource,
symbol
}
])
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
Object.assign(assetProfile, assetProfileInImport);
}
}
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
assetProfiles[assetProfileIdentifier] = assetProfile;
}
}
return assetProfiles;
}
public async getDividends({
dataSource,
from,
@ -225,36 +347,35 @@ export class DataProviderService implements OnModuleInit {
const granularityQuery =
aGranularity === 'month'
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: '';
? Prisma.sql`AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: Prisma.empty;
const rangeQuery =
from && to
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format(
? Prisma.sql`AND date >= ${format(from, DATE_FORMAT)}::timestamp AND date <= ${format(
to,
DATE_FORMAT
)}'`
: '';
)}::timestamp`
: Prisma.empty;
const dataSources = aItems.map(({ dataSource }) => {
return dataSource;
});
const symbols = aItems.map(({ symbol }) => {
return symbol;
});
try {
const queryRaw = `
SELECT *
FROM "MarketData"
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
AND "symbol" IN ('${symbols.join(
`','`
)}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRawUnsafe(queryRaw);
const marketDataByGranularity: MarketData[] = await this.prismaService
.$queryRaw`
SELECT *
FROM "MarketData"
WHERE "dataSource"::text IN (${Prisma.join(dataSources)})
AND "symbol" IN (${Prisma.join(symbols)})
${granularityQuery}
${rangeQuery}
ORDER BY date;`;
response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData;

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

@ -22,7 +22,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -33,14 +33,18 @@ import { addDays, format, isSameDay, isToday } from 'date-fns';
import { isNumber } from 'lodash';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit
{
private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
) {
) {}
public onModuleInit() {
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
}

10
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -23,7 +23,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
@ -44,7 +44,9 @@ import {
import { uniqBy } from 'lodash';
@Injectable()
export class FinancialModelingPrepService implements DataProviderInterface {
export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = {
'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'Russia',
@ -57,7 +59,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService,
private readonly prismaService: PrismaService
) {
) {}
public onModuleInit() {
this.apiKey = this.configurationService.get(
'API_KEY_FINANCIAL_MODELING_PREP'
);

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

@ -105,7 +105,10 @@ export class ManualService implements DataProviderInterface {
return {};
}
const value = await this.scrape(symbolProfile.scraperConfiguration);
const value = await this.scrape({
symbol,
scraperConfiguration: symbolProfile.scraperConfiguration
});
return {
[symbol]: {
@ -170,7 +173,10 @@ export class ManualService implements DataProviderInterface {
symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => {
try {
const marketPrice = await this.scrape(scraperConfiguration);
const marketPrice = await this.scrape({
scraperConfiguration,
symbol
});
return { marketPrice, symbol };
} catch (error) {
Logger.error(
@ -267,13 +273,23 @@ export class ManualService implements DataProviderInterface {
};
}
public async test(scraperConfiguration: ScraperConfiguration) {
return this.scrape(scraperConfiguration);
public async test({
scraperConfiguration,
symbol
}: {
scraperConfiguration: ScraperConfiguration;
symbol: string;
}) {
return this.scrape({ scraperConfiguration, symbol });
}
private async scrape(
scraperConfiguration: ScraperConfiguration
): Promise<number> {
private async scrape({
scraperConfiguration,
symbol
}: {
scraperConfiguration: ScraperConfiguration;
symbol: string;
}): Promise<number> {
let locale = scraperConfiguration.locale;
const response = await fetch(scraperConfiguration.url, {
@ -283,6 +299,12 @@ export class ManualService implements DataProviderInterface {
)
});
if (!response.ok) {
throw new Error(
`Failed to scrape the market price for ${symbol} (${this.getName()}): ${response.status} ${response.statusText} at ${scraperConfiguration.url}`
);
}
let value: string;
if (response.headers.get('content-type')?.includes('application/json')) {

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

@ -1,16 +1,16 @@
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
@Injectable()
export class I18nService {
export class I18nService implements OnModuleInit {
private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
public constructor() {
public onModuleInit() {
this.loadFiles();
}

2
apps/api/src/services/interfaces/environment.interface.ts

@ -10,6 +10,7 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;
@ -19,6 +20,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_BULL_BOARD: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;

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

@ -9,6 +9,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import ms from 'ms';
@ -17,6 +19,18 @@ import { DataGatheringProcessor } from './data-gathering.processor';
@Module({
imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: DATA_GATHERING_QUEUE,
options: {
displayName: 'Data Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
limiter: {
duration: ms('4 seconds'),

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

@ -1,5 +1,5 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -13,6 +13,8 @@ import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
@ -22,6 +24,19 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
exports: [BullModule, PortfolioSnapshotService],
imports: [
AccountBalanceModule,
ActivitiesModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: {
displayName: 'Portfolio Snapshot Computation',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: {
@ -36,7 +51,6 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
RedisCacheModule
],
providers: [

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

@ -1,5 +1,5 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -23,9 +23,9 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue
export class PortfolioSnapshotProcessor {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly configurationService: ConfigurationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService
) {}
@ -47,7 +47,7 @@ export class PortfolioSnapshotProcessor {
);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
await this.activitiesService.getActivitiesForPortfolioCalculator({
filters: job.data.filters,
userCurrency: job.data.userCurrency,
userId: job.data.userId,

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

@ -10,19 +10,21 @@ import {
resolveMarketCondition
} from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { isWeekend } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable()
export class TwitterBotService {
export class TwitterBotService implements OnModuleInit {
private twitterClient: TwitterApiReadWrite;
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly symbolService: SymbolService
) {
) {}
public onModuleInit() {
this.twitterClient = new TwitterApi({
accessSecret: this.configurationService.get(
'TWITTER_ACCESS_TOKEN_SECRET'

4
apps/client/proxy.conf.json

@ -1,4 +1,8 @@
{
"/admin/queues": {
"target": "http://0.0.0.0:3333",
"secure": false
},
"/api": {
"target": "http://0.0.0.0:3333",
"secure": false

140
apps/client/src/app/app.component.ts

@ -10,12 +10,13 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
DOCUMENT,
HostBinding,
Inject,
OnDestroy,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser';
import {
@ -30,15 +31,13 @@ import { DataSource } from '@prisma/client';
import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { filter } from 'rxjs/operators';
import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogParams } from './components/holding-detail-dialog/interfaces/interfaces';
import { GfAppQueryParams } from './interfaces/interfaces';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@Component({
@ -48,11 +47,7 @@ import { UserService } from './services/user/user.service';
styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html'
})
export class GfAppComponent implements OnDestroy, OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
export class GfAppComponent implements OnInit {
public canCreateAccount: boolean;
public currentRoute: string;
public currentSubRoute: string;
@ -67,52 +62,54 @@ export class GfAppComponent implements OnDestroy, OnInit {
public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink;
public showFooter = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@Inject(DOCUMENT) private document: Document,
private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private route: ActivatedRoute,
private router: Router,
private title: Title,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
public user: User | undefined;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly document = inject(DOCUMENT);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly title = inject(Title);
private readonly userService = inject(UserService);
public constructor() {
this.initializeTheme();
this.user = undefined;
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['holdingDetailDialog'] &&
params['symbol']
) {
this.openHoldingDetailDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
({ dataSource, holdingDetailDialog, symbol }: GfAppQueryParams) => {
if (dataSource && holdingDetailDialog && symbol) {
this.openHoldingDetailDialog({
dataSource,
symbol
});
}
}
});
);
addIcons({ openOutline });
}
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
@ -131,7 +128,7 @@ export class GfAppComponent implements OnDestroy, OnInit {
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) ||
internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) &&
this.user?.settings?.viewMode !== 'ZEN'
@ -144,18 +141,18 @@ export class GfAppComponent implements OnDestroy, OnInit {
if (
(this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) ||
internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.activities.path) ||
internalRoutes.portfolio.subRoutes?.activities.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.allocations.path) ||
internalRoutes.portfolio.subRoutes?.allocations.path) ||
(this.currentRoute === internalRoutes.zen.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path)
internalRoutes.home.subRoutes?.holdings.path)
) {
this.hasPermissionToChangeFilters = true;
} else {
@ -201,7 +198,7 @@ export class GfAppComponent implements OnDestroy, OnInit {
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
this.user = state.user;
@ -226,31 +223,31 @@ export class GfAppComponent implements OnDestroy, OnInit {
}
public onClickSystemMessage() {
if (this.user.systemMessage.routerLink) {
this.router.navigate(this.user.systemMessage.routerLink);
const systemMessage = this.user?.systemMessage;
if (!systemMessage) {
return;
}
if (systemMessage.routerLink) {
void this.router.navigate(systemMessage.routerLink);
} else {
this.notificationService.alert({
title: this.user.systemMessage.message
title: systemMessage.message
});
}
}
public onCreateAccount() {
this.tokenStorageService.signOut();
this.userService.signOut();
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
this.userService.signOut();
document.location.href = `/${document.documentElement.lang}`;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializeTheme(userPreferredColorScheme?: ColorScheme) {
const isDarkTheme = userPreferredColorScheme
? userPreferredColorScheme === 'DARK'
@ -274,14 +271,11 @@ export class GfAppComponent implements OnDestroy, OnInit {
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open<
GfHoldingDetailDialogComponent,
HoldingDetailDialogParams
>(GfHoldingDetailDialogComponent, {
const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
autoFocus: false,
data: {
dataSource,
@ -296,15 +290,21 @@ export class GfAppComponent implements OnDestroy, OnInit {
),
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) &&
hasPermission(
this.user?.permissions,
permissions.createActivity
) &&
!this.user?.settings?.isRestrictedView,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
hasPermissionToUpdateOrder:
hasPermissionToUpdateActivity:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) &&
hasPermission(
this.user?.permissions,
permissions.updateActivity
) &&
!this.user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale
},
@ -314,9 +314,9 @@ export class GfAppComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.router.navigate([], {
void this.router.navigate([], {
queryParams: {
dataSource: null,
holdingDetailDialog: null,
@ -342,6 +342,6 @@ export class GfAppComponent implements OnDestroy, OnInit {
this.document
.querySelector('meta[name="theme-color"]')
.setAttribute('content', themeColor);
?.setAttribute('content', themeColor);
}
}

11
apps/client/src/app/components/access-table/access-table.component.html

@ -39,7 +39,7 @@
getPublicUrl(element.id)
}}</a>
</div>
@if (user?.settings?.isExperimentalFeatures) {
@if (user()?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
@ -69,7 +69,7 @@
class="no-max-width"
xPosition="before"
>
@if (user?.settings?.isExperimentalFeatures) {
@if (user()?.settings?.isExperimentalFeatures) {
<button mat-menu-item (click)="onUpdateAccess(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
@ -86,7 +86,8 @@
</button>
}
@if (
user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC'
user()?.settings?.isExperimentalFeatures ||
element.type === 'PUBLIC'
) {
<hr class="my-0" />
}
@ -100,7 +101,7 @@
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr *matHeaderRowDef="displayedColumns()" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns()" mat-row></tr>
</table>
</div>

76
apps/client/src/app/components/access-table/access-table.component.ts

@ -7,11 +7,12 @@ import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
EventEmitter,
Input,
OnChanges,
Output
effect,
inject,
input,
output
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
@ -46,23 +47,32 @@ import ms from 'ms';
templateUrl: './access-table.component.html',
styleUrls: ['./access-table.component.scss']
})
export class GfAccessTableComponent implements OnChanges {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Input() user: User;
@Output() accessDeleted = new EventEmitter<string>();
@Output() accessToUpdate = new EventEmitter<string>();
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public displayedColumns = [];
public constructor(
private clipboard: Clipboard,
private notificationService: NotificationService,
private snackBar: MatSnackBar
) {
export class GfAccessTableComponent {
public readonly accesses = input.required<Access[]>();
public readonly showActions = input<boolean>(false);
public readonly user = input.required<User>();
public readonly accessDeleted = output<string>();
public readonly accessToUpdate = output<string>();
protected readonly baseUrl = window.location.origin;
protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly displayedColumns = computed(() => {
const columns = ['alias', 'grantee', 'type', 'details'];
if (this.showActions()) {
columns.push('actions');
}
return columns;
});
private readonly clipboard = inject(Clipboard);
private readonly notificationService = inject(NotificationService);
private readonly snackBar = inject(MatSnackBar);
public constructor() {
addIcons({
copyOutline,
createOutline,
@ -72,27 +82,19 @@ export class GfAccessTableComponent implements OnChanges {
lockOpenOutline,
removeCircleOutline
});
}
public ngOnChanges() {
this.displayedColumns = ['alias', 'grantee', 'type', 'details'];
if (this.showActions) {
this.displayedColumns.push('actions');
}
if (this.accesses) {
this.dataSource = new MatTableDataSource(this.accesses);
}
effect(() => {
this.dataSource.data = this.accesses() ?? [];
});
}
public getPublicUrl(aId: string): string {
const languageCode = this.user.settings.language;
protected getPublicUrl(aId: string) {
const languageCode = this.user().settings.language;
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
}
public onCopyUrlToClipboard(aId: string): void {
protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId));
this.snackBar.open(
@ -104,7 +106,7 @@ export class GfAccessTableComponent implements OnChanges {
);
}
public onDeleteAccess(aId: string) {
protected onDeleteAccess(aId: string) {
this.notificationService.confirm({
confirmFn: () => {
this.accessDeleted.emit(aId);
@ -114,7 +116,7 @@ export class GfAccessTableComponent implements OnChanges {
});
}
public onUpdateAccess(aId: string) {
protected onUpdateAccess(aId: string) {
this.accessToUpdate.emit(aId);
}
}

34
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -26,11 +26,12 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog';
@ -49,8 +50,7 @@ import {
} from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { forkJoin } from 'rxjs';
import { AccountDetailDialogParams } from './interfaces/interfaces';
@ -77,7 +77,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'],
templateUrl: 'account-detail-dialog.html'
})
export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
export class GfAccountDetailDialogComponent implements OnInit {
public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[];
public activitiesCount: number;
@ -104,18 +104,17 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public user: User;
public valueInBaseCurrency: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>,
private router: Router,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -154,7 +153,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService
.postAccountBalance(accountBalance)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
@ -163,7 +162,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public onDeleteAccountBalance(aId: string) {
this.dataService
.deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
@ -176,7 +175,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.dataService
.fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
downloadAsFile({
content: data,
@ -212,7 +211,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
private fetchAccount() {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
({
activitiesCount,
@ -287,7 +286,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
@ -304,7 +303,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
forkJoin({
accountBalances: this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)),
.pipe(takeUntilDestroyed(this.destroyRef)),
portfolioPerformance: this.dataService
.fetchPortfolioPerformance({
filters: [
@ -317,7 +316,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
withExcludedAccounts: true,
withItems: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
}).subscribe({
error: () => {
this.isLoadingChart = false;
@ -360,7 +359,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
}
]
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = holdings;
@ -374,9 +373,4 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.fetchChart();
this.fetchPortfolioHoldings();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

55
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -1,5 +1,8 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -7,6 +10,7 @@ import {
} from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services';
@ -15,10 +19,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
DestroyRef,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormGroup,
@ -41,6 +46,7 @@ import {
chevronUpCircleOutline,
ellipsisHorizontal,
ellipsisVertical,
openOutline,
pauseOutline,
playOutline,
removeCircleOutline,
@ -48,8 +54,6 @@ import {
} from 'ionicons/icons';
import { get } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -69,7 +73,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./admin-jobs.scss'],
templateUrl: './admin-jobs.html'
})
export class GfAdminJobsComponent implements OnDestroy, OnInit {
export class GfAdminJobsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort;
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW;
@ -81,6 +85,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public displayedColumns = [
'index',
'type',
@ -93,21 +98,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
'status',
'actions'
];
public hasPermissionToAccessBullBoard = false;
public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
private unsubscribeSubject = new Subject<void>();
private user: User;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -115,6 +123,11 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale
);
this.hasPermissionToAccessBullBoard = hasPermission(
this.user.permissions,
permissions.accessAdminControlBullBoard
);
}
});
@ -126,6 +139,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
chevronUpCircleOutline,
ellipsisHorizontal,
ellipsisVertical,
openOutline,
pauseOutline,
playOutline,
removeCircleOutline,
@ -139,7 +153,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
});
this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
const currentFilter = this.filterForm.get('status').value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
@ -151,7 +165,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onDeleteJob(aId: string) {
this.adminService
.deleteJob(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchJobs();
});
@ -162,7 +176,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
@ -171,12 +185,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onExecuteJob(aId: string) {
this.adminService
.executeJob(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchJobs();
});
}
public onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({
title: JSON.stringify(aData, null, ' ')
@ -189,17 +215,12 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true;
this.adminService
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs);
this.dataSource.sort = this.sort;

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

@ -1,6 +1,15 @@
<div class="container">
<div class="row">
<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">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status">

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

@ -26,10 +26,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
DestroyRef,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
@ -63,7 +64,7 @@ import {
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { distinctUntilChanged } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component';
@ -95,9 +96,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html'
})
export class GfAdminMarketDataComponent
implements AfterViewInit, OnDestroy, OnInit
{
export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@ -140,6 +139,11 @@ export class GfAdminMarketDataComponent
id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`,
type: 'PRESET_ID' as Filter['type']
},
{
id: 'NO_ACTIVITIES',
label: $localize`No Activities`,
type: 'PRESET_ID' as Filter['type']
}
];
public benchmarks: Partial<SymbolProfile>[];
@ -161,13 +165,12 @@ export class GfAdminMarketDataComponent
public totalItems = 0;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
public adminMarketDataService: AdminMarketDataService,
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -204,7 +207,7 @@ export class GfAdminMarketDataComponent
this.displayedColumns.push('actions');
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (
params['assetProfileDialog'] &&
@ -221,7 +224,7 @@ export class GfAdminMarketDataComponent
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -233,7 +236,7 @@ export class GfAdminMarketDataComponent
});
this.filters$
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((filters) => {
this.activeFilters = filters;
@ -297,7 +300,7 @@ export class GfAdminMarketDataComponent
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
@ -308,7 +311,7 @@ export class GfAdminMarketDataComponent
public onGatherMax() {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
@ -319,7 +322,7 @@ export class GfAdminMarketDataComponent
public onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
@ -329,14 +332,14 @@ export class GfAdminMarketDataComponent
}: AssetProfileIdentifier) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
@ -353,11 +356,6 @@ export class GfAdminMarketDataComponent
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadData(
{
pageIndex,
@ -374,7 +372,7 @@ export class GfAdminMarketDataComponent
this.pageSize =
this.activeFilters.length === 1 &&
this.activeFilters[0].type === 'PRESET_ID'
? undefined
? Number.MAX_SAFE_INTEGER
: DEFAULT_PAGE_SIZE;
if (pageIndex === 0 && this.paginator) {
@ -394,7 +392,7 @@ export class GfAdminMarketDataComponent
skip: pageIndex * this.pageSize,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, marketData }) => {
this.totalItems = count;
@ -425,7 +423,7 @@ export class GfAdminMarketDataComponent
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -447,7 +445,7 @@ export class GfAdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
(newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => {
if (newAssetProfileIdentifier) {
@ -463,7 +461,7 @@ export class GfAdminMarketDataComponent
private openCreateAssetProfileDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -481,7 +479,7 @@ export class GfAdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (!result) {
this.router.navigate(['.'], { relativeTo: this.route });
@ -494,7 +492,7 @@ export class GfAdminMarketDataComponent
if (addAssetProfile && dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.loadData();
});

61
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -5,7 +5,11 @@ import {
PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
AssetClassSelectorOption,
@ -33,12 +37,13 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
ElementRef,
Inject,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AbstractControl,
FormBuilder,
@ -83,8 +88,8 @@ import {
serverOutline
} from 'ionicons/icons';
import ms from 'ms';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces';
@ -117,7 +122,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss'],
templateUrl: 'asset-profile-dialog.html'
})
export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
export class GfAssetProfileDialogComponent implements OnInit {
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
@ -138,7 +143,6 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
});
public assetSubClassOptions: AssetClassSelectorOption[] = [];
public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({
@ -180,12 +184,14 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
);
public benchmarks: Partial<SymbolProfile>[];
public canEditAssetProfile = true;
public countries: {
[code: string]: { name: string; value: number };
};
public currencies: string[] = [];
public dateRangeOptions = [
{
label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -236,14 +242,13 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
public adminMarketDataService: AdminMarketDataService,
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
@ -260,7 +265,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}
public get canSaveAssetProfileIdentifier() {
return !this.assetProfileForm.dirty;
return !this.assetProfileForm.dirty && this.canEditAssetProfile;
}
public ngOnInit() {
@ -277,7 +282,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ settings }) => {
this.isDataGatheringEnabled =
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true;
@ -286,7 +291,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -295,7 +300,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
this.assetProfileForm
.get('assetClass')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((assetClass) => {
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
@ -318,12 +323,17 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile;
this.assetClassLabel = translate(this.assetProfile?.assetClass);
this.assetSubClassLabel = translate(this.assetProfile?.assetSubClass);
this.canEditAssetProfile = !isCurrency(
getCurrencyFromSymbol(this.data.symbol)
);
this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => {
@ -390,6 +400,10 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
url: this.assetProfile?.url ?? ''
});
if (!this.canEditAssetProfile) {
this.assetProfileForm.disable();
}
this.assetProfileForm.markAsPristine();
this.changeDetectorRef.markForCheck();
@ -399,7 +413,9 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public onCancelEditAssetProfileIdentifierMode() {
this.isEditAssetProfileIdentifierMode = false;
this.assetProfileForm.enable();
if (this.canEditAssetProfile) {
this.assetProfileForm.enable();
}
this.assetProfileIdentifierForm.reset();
}
@ -420,7 +436,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}: AssetProfileIdentifier) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
@ -433,7 +449,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
} & AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, range, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
@ -446,7 +462,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService
.postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.dataService.updateInfo();
@ -648,7 +664,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const newAssetProfileIdentifier = {
@ -698,7 +714,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ price }) => {
this.notificationService.alert({
@ -729,7 +745,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService
.deleteBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.dataService.updateInfo();
@ -739,11 +755,6 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
public onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm.valid) {
this.onSubmitAssetProfileForm();

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

@ -300,6 +300,7 @@
</div>
<form
#assetProfileFormElement
[class.d-none]="!canEditAssetProfile"
[formGroup]="assetProfileForm"
(keyup.enter)="assetProfileForm.valid && onSubmitAssetProfileForm()"
(ngSubmit)="onSubmitAssetProfileForm()"
@ -358,7 +359,9 @@
<mat-checkbox
color="primary"
[checked]="isBenchmark"
[disabled]="isEditAssetProfileIdentifierMode"
[disabled]="
!canEditAssetProfile || isEditAssetProfileIdentifierMode
"
(change)="
isBenchmark
? onUnsetBenchmark({
@ -581,7 +584,11 @@
<mat-checkbox
color="primary"
[checked]="isDataGatheringEnabled && (assetProfile?.isActive ?? false)"
[disabled]="!isDataGatheringEnabled || isEditAssetProfileIdentifierMode"
[disabled]="
!canEditAssetProfile ||
!isDataGatheringEnabled ||
isEditAssetProfileIdentifierMode
"
(change)="onToggleIsActive($event)"
>
<ng-container i18n>Data Gathering</ng-container>

18
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts

@ -10,9 +10,10 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AbstractControl,
FormBuilder,
@ -31,7 +32,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { DataSource } from '@prisma/client';
import { isISO4217CurrencyCode } from 'class-validator';
import { Subject, switchMap, takeUntil } from 'rxjs';
import { switchMap } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@ -52,19 +53,19 @@ import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
styleUrls: ['./create-asset-profile-dialog.component.scss'],
templateUrl: 'create-asset-profile-dialog.html'
})
export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
export class GfCreateAssetProfileDialogComponent implements OnInit {
public createAssetProfileForm: FormGroup;
public ghostfolioPrefix = `${ghostfolioPrefix}_`;
public mode: CreateAssetProfileDialogMode;
private customCurrencies: string[];
private dataSourceForExchangeRates: DataSource;
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly adminService: AdminService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly dataService: DataService,
private readonly destroyRef: DestroyRef,
public readonly dialogRef: MatDialogRef<GfCreateAssetProfileDialogComponent>,
public readonly formBuilder: FormBuilder
) {}
@ -125,7 +126,7 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
symbol: `${DEFAULT_CURRENCY}${currency}`
});
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({
@ -154,11 +155,6 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
return false;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private atLeastOneValid(control: AbstractControl): ValidationErrors {
const addCurrencyControl = control.get('addCurrency');
const addSymbolControl = control.get('addSymbol');
@ -189,7 +185,7 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
private initialize() {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataProviders, settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];

30
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -22,7 +22,13 @@ import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectorRef,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
@ -50,8 +56,6 @@ import {
trashOutline
} from 'ionicons/icons';
import ms, { StringValue } from 'ms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
imports: [
@ -72,7 +76,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./admin-overview.scss'],
templateUrl: './admin-overview.html'
})
export class GfAdminOverviewComponent implements OnDestroy, OnInit {
export class GfAdminOverviewComponent implements OnInit {
public activitiesCount: number;
public couponDuration: StringValue = '14 days';
public coupons: Coupon[];
@ -88,13 +92,12 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
public user: User;
public version: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService
@ -102,7 +105,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
this.info = this.dataService.fetchInfo();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -219,7 +222,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
confirmFn: () => {
this.cacheService
.flush()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
@ -268,7 +271,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
public onSyncDemoUserAccount() {
this.adminService
.syncDemoUserAccount()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.snackBar.open(
'✅ ' + $localize`Demo user account has been synced.`,
@ -280,15 +283,10 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminData() {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activitiesCount, settings, userCount, version }) => {
this.activitiesCount = activitiesCount;
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
@ -320,7 +318,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
.putAdminSetting(key, {
value: value || value === false ? JSON.stringify(value) : undefined
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();

6
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -52,7 +52,11 @@
<ng-container i18n>Accounts</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.accountCount }}
<gf-value
class="d-inline-block justify-content-end"
[locale]="locale"
[value]="element.accountCount"
/>
</td>
</ng-container>

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

@ -1,18 +1,22 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
DestroyRef,
Input,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
@ -29,7 +33,6 @@ import {
} from 'ionicons/icons';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-dialog/interfaces/interfaces';
@ -38,6 +41,7 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfEntityLogoComponent,
GfValueComponent,
IonIcon,
MatButtonModule,
MatMenuModule,
@ -49,7 +53,9 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
styleUrls: ['./admin-platform.component.scss'],
templateUrl: './admin-platform.component.html'
})
export class GfAdminPlatformComponent implements OnDestroy, OnInit {
export class GfAdminPlatformComponent implements OnInit {
@Input() locale = getLocale();
@ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<Platform>();
@ -57,12 +63,11 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
public displayedColumns = ['name', 'url', 'accounts', 'actions'];
public platforms: Platform[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private notificationService: NotificationService,
@ -71,7 +76,7 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['createPlatformDialog']) {
this.openCreatePlatformDialog();
@ -113,20 +118,15 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deletePlatform(aId: string) {
this.adminService
.deletePlatform(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchPlatforms();
@ -137,7 +137,7 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
private fetchPlatforms() {
this.adminService
.fetchPlatforms()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((platforms) => {
this.platforms = platforms;
@ -169,17 +169,17 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((platform: CreatePlatformDto | null) => {
if (platform) {
this.adminService
.postPlatform(platform)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchPlatforms();
@ -217,17 +217,17 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((platform: UpdatePlatformDto | null) => {
if (platform) {
this.adminService
.putPlatform(platform)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchPlatforms();

17
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts

@ -2,12 +2,7 @@ import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import {
FormBuilder,
FormGroup,
@ -23,7 +18,6 @@ import {
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Subject } from 'rxjs';
import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
@ -43,11 +37,9 @@ import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-platform-dialog.scss'],
templateUrl: 'create-or-update-platform-dialog.html'
})
export class GfCreateOrUpdatePlatformDialogComponent implements OnDestroy {
export class GfCreateOrUpdatePlatformDialogComponent {
public platformForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdatePlatformDialogComponent>,
@ -90,9 +82,4 @@ export class GfCreateOrUpdatePlatformDialogComponent implements OnDestroy {
console.error(error);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

27
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -40,9 +40,21 @@
</mat-card-actions>
</mat-card>
}
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="name"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@ -102,7 +114,12 @@
</ng-container>
<ng-container matColumnDef="assetProfileCount">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<th
*matHeaderCellDef
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="assetProfileCount"
>
<ng-container i18n>Asset Profiles</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -193,13 +210,13 @@
<div class="mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform />
<gf-admin-platform [locale]="user?.settings?.locale" />
</div>
</div>
<div class="row">
<div class="col">
<h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag />
<gf-admin-tag [locale]="user?.settings?.locale" />
</div>
</div>
</div>

33
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -22,20 +22,24 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
DestroyRef,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { ellipsisHorizontal, trashOutline } from 'ionicons/icons';
import { get } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
import { catchError, filter, of } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -52,6 +56,7 @@ import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
MatCardModule,
MatMenuModule,
MatProgressBarModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
@ -60,7 +65,9 @@ import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html'
})
export class GfAdminSettingsComponent implements OnDestroy, OnInit {
export class GfAdminSettingsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<DataProviderInfo>();
public defaultDateFormat: string;
public displayedColumns = [
@ -74,14 +81,13 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
public isGhostfolioApiKeyValid: boolean;
public isLoading = false;
public pricingUrl: string;
private unsubscribeSubject = new Subject<void>();
private user: User;
public user: User;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService,
private userService: UserService
) {
@ -90,7 +96,7 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -147,11 +153,6 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initialize() {
this.isLoading = true;
@ -159,13 +160,15 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataProviders, settings }) => {
const filteredProviders = dataProviders.filter(({ dataSource }) => {
return dataSource !== 'MANUAL';
});
this.dataSource = new MatTableDataSource(filteredProviders);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
const ghostfolioApiKey = settings[
PROPERTY_API_KEY_GHOSTFOLIO
@ -185,7 +188,7 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;

6
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -45,7 +45,11 @@
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.activityCount }}
<gf-value
class="d-inline-block justify-content-end"
[locale]="locale"
[value]="element.activityCount"
/>
</td>
</ng-container>

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

@ -1,17 +1,21 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
DestroyRef,
Input,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
@ -28,7 +32,6 @@ import {
} from 'ionicons/icons';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { GfCreateOrUpdateTagDialogComponent } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/interfaces/interfaces';
@ -36,6 +39,7 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfValueComponent,
IonIcon,
MatButtonModule,
MatMenuModule,
@ -47,7 +51,9 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
styleUrls: ['./admin-tag.component.scss'],
templateUrl: './admin-tag.component.html'
})
export class GfAdminTagComponent implements OnDestroy, OnInit {
export class GfAdminTagComponent implements OnInit {
@Input() locale = getLocale();
@ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<Tag>();
@ -55,11 +61,10 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
public displayedColumns = ['name', 'userId', 'activities', 'actions'];
public tags: Tag[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private notificationService: NotificationService,
@ -68,7 +73,7 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['createTagDialog']) {
this.openCreateTagDialog();
@ -110,20 +115,15 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deleteTag(aId: string) {
this.dataService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchTags();
@ -134,7 +134,7 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
private fetchTags() {
this.dataService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags) => {
this.tags = tags;
@ -165,17 +165,17 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag: CreateTagDto | null) => {
if (tag) {
this.dataService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchTags();
@ -204,17 +204,17 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag: UpdateTagDto | null) => {
if (tag) {
this.dataService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchTags();

17
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts

@ -1,12 +1,7 @@
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import {
FormBuilder,
FormGroup,
@ -21,7 +16,6 @@ import {
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Subject } from 'rxjs';
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
@ -40,11 +34,9 @@ import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class GfCreateOrUpdateTagDialogComponent implements OnDestroy {
export class GfCreateOrUpdateTagDialogComponent {
public tagForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdateTagDialogComponent>,
@ -85,9 +77,4 @@ export class GfCreateOrUpdateTagDialogComponent implements OnDestroy {
console.error(error);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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

@ -1,7 +1,6 @@
import { UserDetailDialogParams } from '@ghostfolio/client/components/user-detail-dialog/interfaces/interfaces';
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
@ -26,10 +25,11 @@ import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
OnDestroy,
DestroyRef,
OnInit,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
@ -56,8 +56,7 @@ import {
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { switchMap, takeUntil, tap } from 'rxjs/operators';
import { switchMap, tap } from 'rxjs/operators';
@Component({
imports: [
@ -76,7 +75,7 @@ import { switchMap, takeUntil, tap } from 'rxjs/operators';
styleUrls: ['./admin-users.scss'],
templateUrl: './admin-users.html'
})
export class GfAdminUsersComponent implements OnDestroy, OnInit {
export class GfAdminUsersComponent implements OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
public dataSource = new MatTableDataSource<AdminUsersResponse['users'][0]>();
@ -90,23 +89,21 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
public isLoading = false;
public pageSize = DEFAULT_PAGE_SIZE;
public routerLinkAdminControlUsers =
internalRoutes.adminControl.subRoutes.users.routerLink;
internalRoutes.adminControl.subRoutes?.users.routerLink;
public totalItems = 0;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
@ -141,7 +138,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
this.userService.stateChanged
.pipe(
takeUntil(this.unsubscribeSubject),
takeUntilDestroyed(this.destroyRef),
tap((state) => {
if (state?.user) {
this.user = state.user;
@ -206,7 +203,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
confirmFn: () => {
this.dataService
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.router.navigate(['..'], { relativeTo: this.route });
});
@ -224,13 +221,12 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
confirmFn: () => {
this.dataService
.updateUserAccessToken(aUserId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accessToken }) => {
this.notificationService.alert({
discardFn: () => {
if (aUserId === this.user.id) {
this.tokenStorageService.signOut();
this.userService.remove();
this.userService.signOut();
document.location.href = `/${document.documentElement.lang}`;
}
@ -261,11 +257,6 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) {
this.isLoading = true;
@ -278,7 +269,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
skip: pageIndex * this.pageSize,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, users }) => {
this.dataSource = new MatTableDataSource(users);
this.totalItems = count;
@ -308,7 +299,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
if (data?.action === 'delete' && data?.userId) {
this.onDeleteUser(data.userId);

21
apps/client/src/app/components/data-provider-status/data-provider-status.component.ts

@ -4,13 +4,14 @@ import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
Input,
OnDestroy,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, map, type Observable, of, Subject, takeUntil } from 'rxjs';
import { catchError, map, type Observable, of } from 'rxjs';
import { DataProviderStatus } from './interfaces/interfaces';
@ -20,14 +21,15 @@ import { DataProviderStatus } from './interfaces/interfaces';
selector: 'gf-data-provider-status',
templateUrl: './data-provider-status.component.html'
})
export class GfDataProviderStatusComponent implements OnDestroy, OnInit {
export class GfDataProviderStatusComponent implements OnInit {
@Input() dataSource: DataSource;
public status$: Observable<DataProviderStatus>;
private unsubscribeSubject = new Subject<void>();
public constructor(private dataService: DataService) {}
public constructor(
private dataService: DataService,
private destroyRef: DestroyRef
) {}
public ngOnInit() {
this.status$ = this.dataService
@ -39,12 +41,7 @@ export class GfDataProviderStatusComponent implements OnDestroy, OnInit {
catchError(() => {
return of({ isHealthy: false });
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

8
apps/client/src/app/components/footer/footer.component.ts

@ -33,13 +33,13 @@ export class GfFooterComponent implements OnChanges {
public hasPermissionToAccessFearAndGreedIndex: boolean;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
publicRoutes.about.subRoutes?.changelog.routerLink;
public routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink;
publicRoutes.about.subRoutes?.license.routerLink;
public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
publicRoutes.about.subRoutes?.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink;
publicRoutes.about.subRoutes?.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;

30
apps/client/src/app/components/header/header.component.ts

@ -24,6 +24,7 @@ import {
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
EventEmitter,
HostListener,
Input,
@ -31,6 +32,7 @@ import {
Output,
ViewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@ -48,8 +50,8 @@ import {
radioButtonOffOutline,
radioButtonOnOutline
} from 'ionicons/icons';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -131,10 +133,9 @@ export class GfHeaderComponent implements OnChanges {
public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dataService: DataService,
private destroyRef: DestroyRef,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService,
@ -146,7 +147,7 @@ export class GfHeaderComponent implements OnChanges {
) {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
@ -224,11 +225,11 @@ export class GfHeaderComponent implements OnChanges {
public onDateRangeChange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
});
}
@ -252,11 +253,11 @@ export class GfHeaderComponent implements OnChanges {
this.dataService
.putUserSetting(userSetting)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
});
}
@ -301,7 +302,7 @@ export class GfHeaderComponent implements OnChanges {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
if (data?.accessToken) {
this.dataService
@ -314,7 +315,7 @@ export class GfHeaderComponent implements OnChanges {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
@ -331,7 +332,7 @@ export class GfHeaderComponent implements OnChanges {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
const userLanguage = user?.settings?.language;
@ -342,9 +343,4 @@ export class GfHeaderComponent implements OnChanges {
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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

@ -35,10 +35,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
@ -67,8 +68,7 @@ import {
walletOutline
} from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators';
import { switchMap } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -102,7 +102,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./holding-detail-dialog.component.scss'],
templateUrl: 'holding-detail-dialog.html'
})
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
export class GfHoldingDetailDialogComponent implements OnInit {
public activitiesCount: number;
public accounts: Account[];
public assetClass: string;
@ -158,11 +158,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public user: User;
public value: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder,
@ -192,7 +191,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.holdingForm
.get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => {
return id === undefined;
@ -217,7 +216,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
switchMap(() => {
return this.userService.get(true);
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
} else {
@ -227,7 +226,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
});
@ -236,7 +235,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.fetchAccounts({
filters
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accounts }) => {
this.accounts = accounts;
@ -249,7 +248,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities);
@ -261,7 +260,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
({
activitiesCount,
@ -524,7 +523,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -581,8 +580,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
};
this.dataService
.postOrder(activity)
.pipe(takeUntil(this.unsubscribeSubject))
.postActivity(activity)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink
@ -599,7 +598,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataService
.fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
downloadAsFile({
content: data,
@ -629,18 +628,13 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchMarketData() {
this.dataService
.fetchMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ marketData }) => {
this.marketDataItems = marketData;

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

@ -310,6 +310,9 @@
<gf-value
i18n
size="medium"
[enableCopyToClipboardButton]="
user?.settings?.isExperimentalFeatures
"
[hidden]="!SymbolProfile?.symbol"
[value]="SymbolProfile?.symbol"
>Symbol</gf-value
@ -318,6 +321,9 @@
<div class="col-6 mb-3">
<gf-value
size="medium"
[enableCopyToClipboardButton]="
user?.settings?.isExperimentalFeatures
"
[hidden]="!SymbolProfile?.isin"
[value]="SymbolProfile?.isin"
>ISIN</gf-value
@ -414,7 +420,7 @@
<gf-tags-selector
formControlName="tags"
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[readonly]="!data.hasPermissionToUpdateOrder"
[readonly]="!data.hasPermissionToUpdateActivity"
[tagsAvailable]="tagsAvailable"
/>
</form>

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

Loading…
Cancel
Save