Browse Source

Merge branch 'main' into next

pull/6679/head
Thomas Kaul 7 days ago
parent
commit
2a489a9cb8
  1. 2
      .github/workflows/build-code.yml
  2. 115
      CHANGELOG.md
  3. 6
      README.md
  4. 70
      apps/api/src/app/activities/activities.controller.ts
  5. 12
      apps/api/src/app/activities/activities.module.ts
  6. 75
      apps/api/src/app/activities/activities.service.ts
  7. 2
      apps/api/src/app/admin/admin.controller.ts
  8. 4
      apps/api/src/app/admin/admin.module.ts
  9. 8
      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. 5
      apps/api/src/app/export/export.controller.ts
  19. 4
      apps/api/src/app/export/export.module.ts
  20. 11
      apps/api/src/app/export/export.service.ts
  21. 2
      apps/api/src/app/import/import.controller.ts
  22. 4
      apps/api/src/app/import/import.module.ts
  23. 10
      apps/api/src/app/import/import.service.ts
  24. 10
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  25. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  26. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  27. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  28. 13
      apps/api/src/app/portfolio/current-rate.service.ts
  29. 11
      apps/api/src/app/portfolio/portfolio.controller.ts
  30. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  31. 61
      apps/api/src/app/portfolio/portfolio.service.ts
  32. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  33. 2
      apps/api/src/app/subscription/subscription.service.ts
  34. 4
      apps/api/src/app/user/user.module.ts
  35. 16
      apps/api/src/app/user/user.service.ts
  36. 252
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  37. 12
      apps/api/src/events/asset-profile-changed.event.ts
  38. 65
      apps/api/src/events/asset-profile-changed.listener.ts
  39. 4
      apps/api/src/events/events.module.ts
  40. 30
      apps/api/src/events/portfolio-changed.listener.ts
  41. 2
      apps/api/src/helper/object.helper.spec.ts
  42. 1
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  43. 6
      apps/api/src/main.ts
  44. 28
      apps/api/src/middlewares/bull-board-auth.middleware.ts
  45. 2
      apps/api/src/services/configuration/configuration.service.ts
  46. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  47. 12
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  48. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  49. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  50. 6
      apps/api/src/services/i18n/i18n.service.ts
  51. 2
      apps/api/src/services/interfaces/environment.interface.ts
  52. 14
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  53. 18
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  54. 6
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  55. 8
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  56. 4
      apps/client/proxy.conf.json
  57. 140
      apps/client/src/app/app.component.ts
  58. 11
      apps/client/src/app/components/access-table/access-table.component.html
  59. 76
      apps/client/src/app/components/access-table/access-table.component.ts
  60. 34
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  61. 55
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  62. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  63. 45
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  64. 37
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  65. 18
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  66. 30
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  67. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  68. 40
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  69. 17
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts
  70. 27
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  71. 33
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  72. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  73. 40
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  74. 17
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  75. 33
      apps/client/src/app/components/admin-users/admin-users.component.ts
  76. 21
      apps/client/src/app/components/data-provider-status/data-provider-status.component.ts
  77. 8
      apps/client/src/app/components/footer/footer.component.ts
  78. 30
      apps/client/src/app/components/header/header.component.ts
  79. 38
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  80. 8
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  81. 2
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  82. 29
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  83. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  84. 21
      apps/client/src/app/components/home-market/home-market.component.ts
  85. 27
      apps/client/src/app/components/home-overview/home-overview.component.ts
  86. 25
      apps/client/src/app/components/home-summary/home-summary.component.ts
  87. 17
      apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts
  88. 31
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  89. 21
      apps/client/src/app/components/markets/markets.component.ts
  90. 13
      apps/client/src/app/components/rule/rule.component.ts
  91. 16
      apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts
  92. 21
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  93. 32
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  94. 29
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  95. 48
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  96. 8
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  97. 2
      apps/client/src/app/core/auth.guard.ts
  98. 6
      apps/client/src/app/core/http-response.interceptor.ts
  99. 8
      apps/client/src/app/interfaces/interfaces.ts
  100. 17
      apps/client/src/app/pages/about/about-page.component.ts

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

115
CHANGELOG.md

@ -16,6 +16,121 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Breaking Change**: The `sslmode=prefer` parameter in `DATABASE_URL` is no longer supported. Please update your environment variables (see `.env`) to use `sslmode=require` if _SSL_ is enabled or remove the `sslmode` parameter entirely if _SSL_ is not used.
## Unreleased
### Added
- Added support for filtering by activity type on the activities page (experimental)
## 2.252.0 - 2026-03-02
### 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
- Refreshed the cryptocurrencies list
- 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

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" />

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

@ -37,28 +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, Type as ActivityType } 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,
@ -73,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
});
}
@ -105,9 +109,10 @@ 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('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@ -122,7 +127,7 @@ export class OrderController {
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange(dateRange));
({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
}
const filters = this.apiService.buildFiltersFromQueryParams({
@ -135,14 +140,18 @@ export class OrderController {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
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,
sortDirection,
startDate,
types,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
@ -158,7 +167,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> {
@ -166,7 +175,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,
@ -187,11 +196,11 @@ 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: [
@ -227,7 +236,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: {
@ -252,14 +261,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
}
],
@ -267,19 +276,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
@ -302,7 +314,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 {}

75
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,
@ -626,7 +629,7 @@ export class OrderService {
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) {
if (types?.length > 0) {
where.type = { in: types };
}
@ -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: {

2
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;
}

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,

8
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
@ -475,7 +475,7 @@ export class AdminService {
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
await this.activitiesService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([
@ -798,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,

5
apps/api/src/app/export/export.controller.ts

@ -15,6 +15,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service';
@ -33,12 +34,15 @@ export class ExportController {
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -49,6 +53,7 @@ export class ExportController {
return this.exportService.export({
activityIds,
activityTypes,
filters,
userId: this.request.user.id,
userSettings: this.request.user.settings.settings

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';
@ -10,25 +10,27 @@ import {
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client';
import { Platform, Prisma, Type as ActivityType } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash';
@Injectable()
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
) {}
public async export({
activityIds,
activityTypes,
filters,
userId,
userSettings
}: {
activityIds?: string[];
activityTypes?: ActivityType[];
filters?: Filter[];
userId: string;
userSettings: UserSettings;
@ -38,12 +40,13 @@ export class ExportService {
});
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({
let { activities } = await this.activitiesService.getActivities({
filters,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
types: activityTypes,
userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true
});

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,

10
apps/api/src/app/import/import.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 { 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';
@ -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 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,
@ -548,7 +548,7 @@ export class ImportService {
continue;
}
order = await this.orderService.createOrder({
order = await this.activitiesService.createActivity({
comment,
currency,
date,
@ -645,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,

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) &&

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: [],

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,

11
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',
@ -321,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,
@ -640,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,

61
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';
@ -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
@ -1025,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,
@ -1349,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>): {
@ -1672,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,
@ -1841,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
@ -1914,8 +1952,6 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency,
activities: excludedActivities,
@ -1979,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: {

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,

16
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';
@ -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,
@ -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 {}

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

File diff suppressed because it is too large

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

2
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,
@ -3017,7 +3016,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null,
totalBuy: null,
totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null,
dividendInBaseCurrency: null,
emergencyFund: null,

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

@ -68,6 +68,7 @@ export class TransformDataSourceInResponseInterceptor<
'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/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 }),

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

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

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,
@ -344,6 +344,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">

45
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;
@ -166,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,
@ -209,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'] &&
@ -226,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;
@ -238,7 +236,7 @@ export class GfAdminMarketDataComponent
});
this.filters$
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((filters) => {
this.activeFilters = filters;
@ -302,7 +300,7 @@ export class GfAdminMarketDataComponent
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
@ -313,7 +311,7 @@ export class GfAdminMarketDataComponent
public onGatherMax() {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
@ -324,7 +322,7 @@ export class GfAdminMarketDataComponent
public onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
@ -334,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();
}
@ -358,11 +356,6 @@ export class GfAdminMarketDataComponent
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadData(
{
pageIndex,
@ -399,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;
@ -430,7 +423,7 @@ export class GfAdminMarketDataComponent
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -452,7 +445,7 @@ export class GfAdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
(newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => {
if (newAssetProfileIdentifier) {
@ -468,7 +461,7 @@ export class GfAdminMarketDataComponent
private openCreateAssetProfileDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -486,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 });
@ -499,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();
});

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

@ -37,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,
@ -87,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';
@ -121,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
@ -241,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,
@ -282,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;
@ -291,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;
@ -300,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) ?? [];
@ -323,7 +323,7 @@ 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;
@ -436,7 +436,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}: AssetProfileIdentifier) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
@ -449,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();
}
@ -462,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();
@ -664,7 +664,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const newAssetProfileIdentifier = {
@ -714,7 +714,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ price }) => {
this.notificationService.alert({
@ -745,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();
@ -755,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();

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>

2
apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts

@ -11,7 +11,7 @@ export interface HoldingDetailDialogParams {
hasPermissionToAccessAdminControl: boolean;
hasPermissionToCreateActivity: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateOrder: boolean;
hasPermissionToUpdateActivity: boolean;
locale: string;
symbol: string;
}

29
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -19,9 +19,10 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
@ -30,8 +31,6 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
imports: [
@ -51,7 +50,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./home-holdings.scss'],
templateUrl: './home-holdings.html'
})
export class GfHomeHoldingsComponent implements OnDestroy, OnInit {
export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
public deviceType: string;
@ -71,11 +70,10 @@ export class GfHomeHoldingsComponent implements OnDestroy, OnInit {
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
@ -89,13 +87,13 @@ export class GfHomeHoldingsComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -107,7 +105,7 @@ export class GfHomeHoldingsComponent implements OnDestroy, OnInit {
this.hasPermissionToCreateActivity = hasPermission(
this.user.permissions,
permissions.createOrder
permissions.createActivity
);
this.initialize();
@ -117,15 +115,15 @@ export class GfHomeHoldingsComponent implements OnDestroy, OnInit {
});
this.viewModeFormControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((holdingsViewMode) => {
this.dataService
.putUserSetting({ holdingsViewMode })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -149,11 +147,6 @@ export class GfHomeHoldingsComponent implements OnDestroy, OnInit {
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchHoldings() {
const filters = this.userService.getFilters();
@ -193,7 +186,7 @@ export class GfHomeHoldingsComponent implements OnDestroy, OnInit {
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = holdings;

1
apps/client/src/app/components/home-holdings/home-holdings.html

@ -46,7 +46,6 @@
}
<div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }">
<gf-holdings-table
[hasPermissionToShowQuantities]="false"
[holdings]="holdings"
[locale]="user?.settings?.locale"
(holdingClicked)="onHoldingClicked($event)"

21
apps/client/src/app/components/home-market/home-market.component.ts

@ -17,12 +17,11 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
imports: [
@ -35,7 +34,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./home-market.scss'],
templateUrl: './home-market.html'
})
export class GfHomeMarketComponent implements OnDestroy, OnInit {
export class GfHomeMarketComponent implements OnInit {
public benchmarks: Benchmark[];
public deviceType: string;
public fearAndGreedIndex: number;
@ -47,11 +46,10 @@ export class GfHomeMarketComponent implements OnDestroy, OnInit {
public readonly numberOfDays = 365;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
@ -59,7 +57,7 @@ export class GfHomeMarketComponent 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;
@ -82,7 +80,7 @@ export class GfHomeMarketComponent implements OnDestroy, OnInit {
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
this.historicalDataItems = [
@ -99,16 +97,11 @@ export class GfHomeMarketComponent implements OnDestroy, OnInit {
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

27
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -19,14 +19,13 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
imports: [
@ -41,7 +40,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./home-overview.scss'],
templateUrl: './home-overview.html'
})
export class GfHomeOverviewComponent implements OnDestroy, OnInit {
export class GfHomeOverviewComponent implements OnInit {
public deviceType: string;
public errors: AssetProfileIdentifier[];
public hasError: boolean;
@ -57,30 +56,29 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
public routerLinkAccounts = internalRoutes.accounts.routerLink;
public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink;
internalRoutes.portfolio.subRoutes?.activities.routerLink;
public showDetails = false;
public unit: string;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateActivity = hasPermission(
this.user.permissions,
permissions.createOrder
permissions.createActivity
);
this.update();
@ -99,7 +97,7 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
@ -107,17 +105,12 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
});
this.layoutService.shouldReloadContent$
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.update();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private update() {
this.historicalDataItems = null;
this.isLoadingPerformance = true;
@ -126,7 +119,7 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
.fetchPortfolioPerformance({
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, errors, performance }) => {
this.errors = errors;
this.performance = performance;

25
apps/client/src/app/components/home-summary/home-summary.component.ts

@ -13,14 +13,13 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
import { MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
imports: [GfPortfolioSummaryComponent, MatCardModule],
@ -29,7 +28,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./home-summary.scss'],
templateUrl: './home-summary.html'
})
export class GfHomeSummaryComponent implements OnDestroy, OnInit {
export class GfHomeSummaryComponent implements OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionForSubscription: boolean;
@ -40,11 +39,10 @@ export class GfHomeSummaryComponent implements OnDestroy, OnInit {
public summary: PortfolioSummary;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
@ -57,7 +55,7 @@ export class GfHomeSummaryComponent implements OnDestroy, OnInit {
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -77,7 +75,7 @@ export class GfHomeSummaryComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
@ -86,11 +84,11 @@ export class GfHomeSummaryComponent implements OnDestroy, OnInit {
public onChangeEmergencyFund(emergencyFund: number) {
this.dataService
.putUserSetting({ emergencyFund })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -99,17 +97,12 @@ export class GfHomeSummaryComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private update() {
this.isLoading = true;
this.dataService
.fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ summary }) => {
this.summary = summary;
this.isLoading = false;

17
apps/client/src/app/components/home-watchlist/create-watchlist-item-dialog/create-watchlist-item-dialog.component.ts

@ -1,11 +1,6 @@
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import {
AbstractControl,
FormBuilder,
@ -19,7 +14,6 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { Subject } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -36,11 +30,9 @@ import { Subject } from 'rxjs';
styleUrls: ['./create-watchlist-item-dialog.component.scss'],
templateUrl: 'create-watchlist-item-dialog.html'
})
export class GfCreateWatchlistItemDialogComponent implements OnDestroy, OnInit {
export class GfCreateWatchlistItemDialogComponent implements OnInit {
public createWatchlistItemForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly dialogRef: MatDialogRef<GfCreateWatchlistItemDialogComponent>,
public readonly formBuilder: FormBuilder
@ -69,11 +61,6 @@ export class GfCreateWatchlistItemDialogComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private validator(control: AbstractControl): ValidationErrors {
const searchSymbolControl = control.get('searchSymbol');

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

@ -15,9 +15,10 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -25,8 +26,6 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component';
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces';
@ -45,7 +44,7 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
styleUrls: ['./home-watchlist.scss'],
templateUrl: './home-watchlist.html'
})
export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
export class GfHomeWatchlistComponent implements OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateWatchlistItem: boolean;
@ -53,11 +52,10 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
public user: User;
public watchlist: Benchmark[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
@ -69,13 +67,13 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['createWatchlistItemDialog']) {
this.openCreateWatchlistItemDialog();
@ -83,7 +81,7 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -118,7 +116,7 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
}: AssetProfileIdentifier) {
this.dataService
.deleteWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
return this.loadWatchlistData();
@ -126,15 +124,10 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadWatchlistData() {
this.dataService
.fetchWatchlist()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ watchlist }) => {
this.watchlist = watchlist;
@ -145,7 +138,7 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
private openCreateWatchlistItemDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -163,12 +156,12 @@ export class GfHomeWatchlistComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.dataService
.postWatchlistItem({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.loadWatchlistData()
});

21
apps/client/src/app/components/markets/markets.component.ts

@ -19,12 +19,11 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -39,7 +38,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./markets.scss'],
templateUrl: './markets.html'
})
export class GfMarketsComponent implements OnDestroy, OnInit {
export class GfMarketsComponent implements OnInit {
public benchmarks: Benchmark[];
public deviceType: string;
public fearAndGreedIndex: number;
@ -55,18 +54,17 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public readonly numberOfDays = 365;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -79,7 +77,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.dataService
.fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ fearAndGreedIndex }) => {
this.fearAndGreedIndexData = fearAndGreedIndex;
@ -90,7 +88,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
@ -119,9 +117,4 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
this.initialize();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

13
apps/client/src/app/components/rule/rule.component.ts

@ -9,11 +9,13 @@ import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
EventEmitter,
Input,
OnInit,
Output
} 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 +31,6 @@ import {
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject, takeUntil } from 'rxjs';
import { RuleSettingsDialogParams } from './rule-settings-dialog/interfaces/interfaces';
import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-settings-dialog.component';
@ -58,9 +59,8 @@ export class GfRuleComponent implements OnInit {
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>();
private deviceType: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog
) {
@ -94,7 +94,7 @@ export class GfRuleComponent implements OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((settings: RuleSettings) => {
if (settings) {
this.ruleUpdated.emit({
@ -115,9 +115,4 @@ export class GfRuleComponent implements OnInit {
this.ruleUpdated.emit(settings);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

16
apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts

@ -7,9 +7,11 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
@ -21,8 +23,8 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { arrowForwardOutline, checkmarkCircleOutline } from 'ionicons/icons';
import ms from 'ms';
import { interval, Subject } from 'rxjs';
import { take, takeUntil, tap } from 'rxjs/operators';
import { interval } from 'rxjs';
import { take, tap } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
@ -51,11 +53,10 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
public routerLinkPricing = publicRoutes.pricing.routerLink;
public variantIndex: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfSubscriptionInterstitialDialogComponent>
) {
this.variantIndex = Math.floor(
@ -76,7 +77,7 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck();
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
}
@ -84,9 +85,4 @@ export class GfSubscriptionInterstitialDialogComponent implements OnInit {
public closeDialog() {
this.dialogRef.close({});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

21
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -7,10 +7,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormGroup,
@ -28,7 +29,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, Subject, catchError, takeUntil } from 'rxjs';
import { EMPTY, catchError } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@ -48,19 +49,16 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html'
})
export class GfCreateOrUpdateAccessDialogComponent
implements OnDestroy, OnInit
{
export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public accessForm: FormGroup;
public mode: 'create' | 'update';
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private dataService: DataService,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder,
private notificationService: NotificationService
) {
@ -113,11 +111,6 @@ export class GfCreateOrUpdateAccessDialogComponent
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private async createAccess() {
const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value,
@ -144,7 +137,7 @@ export class GfCreateOrUpdateAccessDialogComponent
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close(access);
@ -181,7 +174,7 @@ export class GfCreateOrUpdateAccessDialogComponent
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close(access);

32
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -1,5 +1,4 @@
import { GfAccessTableComponent } from '@ghostfolio/client/components/access-table/access-table.component';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
@ -14,9 +13,10 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
@ -27,8 +27,8 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { addOutline, eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { GfCreateOrUpdateAccessDialogComponent } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dialog/interfaces/interfaces';
@ -52,7 +52,7 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial
styleUrls: ['./user-account-access.scss'],
templateUrl: './user-account-access.html'
})
export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
export class GfUserAccountAccessComponent implements OnInit {
public accessesGet: Access[];
public accessesGive: Access[];
public deviceType: string;
@ -65,18 +65,16 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
});
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
const { globalPermissions } = this.dataService.fetchInfo();
@ -87,7 +85,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -112,7 +110,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccessDialog();
@ -133,7 +131,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
public onDeleteAccess(aId: string) {
this.dataService
.deleteAccess(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.update();
@ -156,13 +154,12 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ accessToken }) => {
this.notificationService.alert({
discardFn: () => {
this.tokenStorageService.signOut();
this.userService.remove();
this.userService.signOut();
document.location.href = `/${document.documentElement.lang}`;
},
@ -182,11 +179,6 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openCreateAccessDialog() {
const dialogRef = this.dialog.open<
GfCreateOrUpdateAccessDialogComponent,
@ -264,7 +256,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
this.dataService
.fetchAccesses()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((accesses) => {
this.accessesGive = accesses;

29
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -14,15 +14,16 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy
DestroyRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import ms, { StringValue } from 'ms';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -38,7 +39,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./user-account-membership.scss'],
templateUrl: './user-account-membership.html'
})
export class GfUserAccountMembershipComponent implements OnDestroy {
export class GfUserAccountMembershipComponent {
public baseCurrency: string;
public coupon: number;
public couponId: string;
@ -54,11 +55,10 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService
@ -73,7 +73,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -118,7 +118,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ sessionUrl }) => {
window.location.href = sessionUrl;
@ -142,7 +142,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ apiKey }) => {
this.notificationService.alert({
@ -180,7 +180,7 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const snackBarRef = this.snackBar.open(
@ -193,14 +193,14 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
snackBarRef
.afterDismissed()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
window.location.reload();
});
snackBarRef
.onAction()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
window.location.reload();
});
@ -210,9 +210,4 @@ export class GfUserAccountMembershipComponent implements OnDestroy {
title: $localize`Please enter your coupon code.`
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

48
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -3,7 +3,6 @@ import {
KEY_TOKEN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
@ -19,9 +18,10 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
FormsModule,
@ -44,8 +44,8 @@ import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { EMPTY, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -66,7 +66,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./user-account-settings.scss'],
templateUrl: './user-account-settings.html'
})
export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
export class GfUserAccountSettingsComponent implements OnInit {
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public currencies: string[] = [];
@ -99,16 +99,14 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private settingsStorageService: SettingsStorageService,
private snackBar: MatSnackBar,
private tokenStorageService: TokenStorageService,
private userService: UserService,
public webAuthnService: WebAuthnService
) {
@ -118,7 +116,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
this.currencies = currencies;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
@ -159,11 +157,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onChangeUserSetting(aKey: string, aValue: string) {
this.dataService
.putUserSetting({ [aKey]: aValue })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -195,11 +193,10 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.tokenStorageService.signOut();
this.userService.remove();
this.userService.signOut();
document.location.href = `/${document.documentElement.lang}`;
});
@ -212,11 +209,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -228,7 +225,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onExport() {
this.dataService
.fetchExport()
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
for (const activity of data.activities) {
delete activity.id;
@ -248,11 +245,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -287,11 +284,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.user = user;
@ -300,11 +297,6 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deregisterDevice() {
this.webAuthnService
.deregister()
@ -314,7 +306,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.update();
@ -344,7 +336,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
return error;
});
}),
takeUntil(this.unsubscribeSubject)
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {

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

@ -27,7 +27,13 @@
<div class="container p-0">
<div class="mb-3 row">
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="user?.id">User ID</gf-value>
<gf-value
i18n
size="medium"
[enableCopyToClipboardButton]="true"
[value]="user?.id"
>User ID</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="user?.role">Role</gf-value>

2
apps/client/src/app/core/auth.guard.ts

@ -68,7 +68,7 @@ export class AuthGuard {
this.dataService
.putUserSetting({ language: document.documentElement.lang })
.subscribe(() => {
this.userService.remove();
this.userService.reset();
setTimeout(() => {
window.location.reload();

6
apps/client/src/app/core/http-response.interceptor.ts

@ -1,4 +1,4 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
@ -32,8 +32,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
public constructor(
private dataService: DataService,
private router: Router,
private tokenStorageService: TokenStorageService,
private snackBar: MatSnackBar,
private userService: UserService,
private webAuthnService: WebAuthnService
) {
this.info = this.dataService.fetchInfo();
@ -115,7 +115,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
if (this.webAuthnService.isEnabled()) {
this.router.navigate(internalRoutes.webauthn.routerLink);
} else {
this.tokenStorageService.signOut();
this.userService.signOut();
}
}
}

8
apps/client/src/app/interfaces/interfaces.ts

@ -0,0 +1,8 @@
import type { Params } from '@angular/router';
import type { DataSource } from '@prisma/client';
export interface GfAppQueryParams extends Params {
dataSource?: DataSource;
holdingDetailDialog?: string;
symbol?: string;
}

17
apps/client/src/app/pages/about/about-page.component.ts

@ -8,9 +8,10 @@ import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
@ -24,8 +25,6 @@ import {
sparklesOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page has-tabs' },
@ -35,17 +34,16 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html'
})
export class AboutPageComponent implements OnDestroy, OnInit {
export class AboutPageComponent implements OnInit {
public deviceType: string;
public hasPermissionForSubscription: boolean;
public tabs: TabConfiguration[] = [];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService,
private userService: UserService
) {
@ -57,7 +55,7 @@ export class AboutPageComponent implements OnDestroy, OnInit {
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
this.tabs = [
{
@ -118,9 +116,4 @@ export class AboutPageComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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

Loading…
Cancel
Save