Browse Source

Merge branch 'main' into bug/fix-save

pull/6486/head
Parth Sharma 1 week ago
committed by GitHub
parent
commit
ba256e2274
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/build-code.yml
  2. 81
      CHANGELOG.md
  3. 65
      apps/api/src/app/activities/activities.controller.ts
  4. 12
      apps/api/src/app/activities/activities.module.ts
  5. 73
      apps/api/src/app/activities/activities.service.ts
  6. 2
      apps/api/src/app/admin/admin.controller.ts
  7. 4
      apps/api/src/app/admin/admin.module.ts
  8. 8
      apps/api/src/app/admin/admin.service.ts
  9. 38
      apps/api/src/app/app.module.ts
  10. 4
      apps/api/src/app/endpoints/ai/ai.module.ts
  11. 7
      apps/api/src/app/endpoints/assets/assets.controller.ts
  12. 6
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  13. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  14. 7
      apps/api/src/app/endpoints/public/public.controller.ts
  15. 4
      apps/api/src/app/endpoints/public/public.module.ts
  16. 4
      apps/api/src/app/export/export.module.ts
  17. 6
      apps/api/src/app/export/export.service.ts
  18. 2
      apps/api/src/app/import/import.controller.ts
  19. 4
      apps/api/src/app/import/import.module.ts
  20. 10
      apps/api/src/app/import/import.service.ts
  21. 10
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  22. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  23. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  24. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  25. 7
      apps/api/src/app/portfolio/current-rate.service.ts
  26. 11
      apps/api/src/app/portfolio/portfolio.controller.ts
  27. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  28. 61
      apps/api/src/app/portfolio/portfolio.service.ts
  29. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  30. 4
      apps/api/src/app/user/user.module.ts
  31. 14
      apps/api/src/app/user/user.service.ts
  32. 12
      apps/api/src/events/asset-profile-changed.event.ts
  33. 65
      apps/api/src/events/asset-profile-changed.listener.ts
  34. 4
      apps/api/src/events/events.module.ts
  35. 30
      apps/api/src/events/portfolio-changed.listener.ts
  36. 2
      apps/api/src/helper/object.helper.spec.ts
  37. 1
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  38. 6
      apps/api/src/main.ts
  39. 28
      apps/api/src/middlewares/bull-board-auth.middleware.ts
  40. 2
      apps/api/src/services/configuration/configuration.service.ts
  41. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  42. 12
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  43. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  44. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  45. 6
      apps/api/src/services/i18n/i18n.service.ts
  46. 2
      apps/api/src/services/interfaces/environment.interface.ts
  47. 14
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  48. 18
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  49. 6
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  50. 8
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  51. 4
      apps/client/proxy.conf.json
  52. 136
      apps/client/src/app/app.component.ts
  53. 34
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  54. 55
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  55. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  56. 45
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  57. 37
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  58. 18
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  59. 30
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  60. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  61. 40
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  62. 17
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts
  63. 27
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  64. 33
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  65. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  66. 40
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  67. 17
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  68. 33
      apps/client/src/app/components/admin-users/admin-users.component.ts
  69. 21
      apps/client/src/app/components/data-provider-status/data-provider-status.component.ts
  70. 8
      apps/client/src/app/components/footer/footer.component.ts
  71. 30
      apps/client/src/app/components/header/header.component.ts
  72. 38
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  73. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  74. 2
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  75. 29
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  76. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  77. 21
      apps/client/src/app/components/home-market/home-market.component.ts
  78. 27
      apps/client/src/app/components/home-overview/home-overview.component.ts
  79. 25
      apps/client/src/app/components/home-summary/home-summary.component.ts
  80. 21
      apps/client/src/app/components/markets/markets.component.ts
  81. 13
      apps/client/src/app/components/rule/rule.component.ts
  82. 5
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  83. 5
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  84. 2
      apps/client/src/app/core/auth.guard.ts
  85. 6
      apps/client/src/app/core/http-response.interceptor.ts
  86. 8
      apps/client/src/app/interfaces/interfaces.ts
  87. 17
      apps/client/src/app/pages/about/about-page.component.ts
  88. 10
      apps/client/src/app/pages/about/about-page.routes.ts
  89. 2
      apps/client/src/app/pages/about/changelog/changelog-page.routes.ts
  90. 2
      apps/client/src/app/pages/about/license/license-page.routes.ts
  91. 2
      apps/client/src/app/pages/about/oss-friends/oss-friends-page.routes.ts
  92. 2
      apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.routes.ts
  93. 12
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts
  94. 2
      apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.routes.ts
  95. 54
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  96. 18
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  97. 17
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  98. 20
      apps/client/src/app/pages/admin/admin-page.routes.ts
  99. 17
      apps/client/src/app/pages/auth/auth-page.component.ts
  100. 2
      apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts

2
.github/workflows/build-code.yml

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

81
CHANGELOG.md

@ -7,13 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added the quantity column to the holdings table of the portfolio holdings page
### Changed
- 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 ### Changed
- Removed the deprecated `committedFunds` from the summary of the portfolio details endpoint
- Upgraded `Nx` from version `22.4.5` to `22.5.3` - Upgraded `Nx` from version `22.4.5` to `22.5.3`
### Fixed ### Fixed
- Fixed the _Save_ button state in the rule settings dialog on the _X-ray_ page - Fixed the _Save_ button state in the rule settings dialog on the _X-ray_ page
- Fixed an issue where the apply and reset filter buttons remained disabled in the assistant
## 2.245.0 - 2026-03-01 ## 2.245.0 - 2026-03-01

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

@ -37,28 +37,32 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel, Prisma } from '@prisma/client'; import { Order, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { OrderService } from './order.service'; import { ActivitiesService } from './activities.service';
@Controller('order') @Controller([
export class OrderController { 'activities',
/** @deprecated */
'order'
])
export class ActivitiesController {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@ -73,29 +77,29 @@ export class OrderController {
filterByTags filterByTags
}); });
return this.orderService.deleteOrders({ return this.activitiesService.deleteActivities({
filters, filters,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteActivity(@Param('id') id: string): Promise<Order> {
const order = await this.orderService.order({ const activity = await this.activitiesService.order({
id, id,
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order) { if (!activity) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.orderService.deleteOrder({ return this.activitiesService.deleteActivity({
id id
}); });
} }
@ -105,7 +109,7 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@ -122,7 +126,7 @@ export class OrderController {
let startDate: Date; let startDate: Date;
if (dateRange) { if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange(dateRange)); ({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
@ -137,7 +141,7 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
sortColumn, sortColumn,
@ -158,7 +162,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById( public async getActivityById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<ActivityResponse> { ): Promise<ActivityResponse> {
@ -166,7 +170,7 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
@ -187,11 +191,11 @@ export class OrderController {
return activity; return activity;
} }
@HasPermission(permissions.createOrder) @HasPermission(permissions.createActivity)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createActivity(@Body() data: CreateOrderDto): Promise<Order> {
try { try {
await this.dataProviderService.validateActivities({ await this.dataProviderService.validateActivities({
activitiesDto: [ activitiesDto: [
@ -227,7 +231,7 @@ export class OrderController {
delete data.dataSource; delete data.dataSource;
const order = await this.orderService.createOrder({ const activity = await this.activitiesService.createActivity({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
@ -252,14 +256,14 @@ export class OrderController {
userId: this.request.user.id userId: this.request.user.id
}); });
if (dataSource && !order.isDraft) { if (dataSource && !activity.isDraft) {
// Gather symbol data in the background, if data source is set // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft // (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
dataSource, dataSource,
date: order.date, date: activity.date,
symbol: data.symbol symbol: data.symbol
} }
], ],
@ -267,19 +271,22 @@ export class OrderController {
}); });
} }
return order; return activity;
} }
@HasPermission(permissions.updateOrder) @HasPermission(permissions.updateActivity)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async updateActivity(
const originalOrder = await this.orderService.order({ @Param('id') id: string,
@Body() data: UpdateOrderDto
) {
const originalActivity = await this.activitiesService.order({
id id
}); });
if (!originalOrder || originalOrder.userId !== this.request.user.id) { if (!originalActivity || originalActivity.userId !== this.request.user.id) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -302,7 +309,7 @@ export class OrderController {
delete data.dataSource; delete data.dataSource;
return this.orderService.updateOrder({ return this.activitiesService.updateActivity({
data: { data: {
...data, ...data,
date, 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 { Module } from '@nestjs/common';
import { OrderController } from './order.controller'; import { ActivitiesController } from './activities.controller';
import { OrderService } from './order.service'; import { ActivitiesService } from './activities.service';
@Module({ @Module({
controllers: [OrderController], controllers: [ActivitiesController],
exports: [OrderService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
CacheModule, CacheModule,
@ -35,6 +35,6 @@ import { OrderService } from './order.service';
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],
providers: [AccountBalanceService, AccountService, OrderService] providers: [AccountBalanceService, AccountService, ActivitiesService]
}) })
export class OrderModule {} export class ActivitiesModule {}

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

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

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

@ -172,7 +172,7 @@ export class AdminController {
let date: Date; let date: Date;
if (dateRange) { if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange); const { startDate } = getIntervalFromDateRange({ dateRange });
date = startDate; 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 { 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -20,6 +20,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
@ -28,7 +29,6 @@ import { QueueModule } from './queue/queue.module';
DemoModule, DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, 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 { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -55,12 +55,12 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -475,7 +475,7 @@ export class AdminService {
if (isCurrencyAssetProfile) { if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol); currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData] = await Promise.all([
@ -798,7 +798,7 @@ export class AdminService {
if (isCurrency(getCurrencyFromSymbol(symbol))) { if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol); currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const lastMarketPrice = lastMarketPriceMap.get( 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 { 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 { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { import {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@ -25,6 +29,7 @@ import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { ActivitiesModule } from './activities/activities.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
@ -48,7 +53,6 @@ import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module'; import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
@ -62,6 +66,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
ActivitiesModule,
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetModule, AssetModule,
@ -69,6 +74,29 @@ import { UserModule } from './user/user.module';
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarksModule, 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({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -94,7 +122,6 @@ import { UserModule } from './user/user.module';
InfoModule, InfoModule,
LogoModule, LogoModule,
MarketDataModule, MarketDataModule,
OrderModule,
PlatformModule, PlatformModule,
PlatformsModule, PlatformsModule,
PortfolioModule, PortfolioModule,
@ -105,7 +132,12 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.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'), rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {

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

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

@ -4,6 +4,7 @@ import { interpolate } from '@ghostfolio/common/helper';
import { import {
Controller, Controller,
Get, Get,
OnModuleInit,
Param, Param,
Res, Res,
Version, Version,
@ -14,12 +15,14 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@Controller('assets') @Controller('assets')
export class AssetsController { export class AssetsController implements OnModuleInit {
private webManifest = ''; private webManifest = '';
public constructor( public constructor(
public readonly configurationService: ConfigurationService public readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
try { try {
this.webManifest = readFileSync( this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'), 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('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' @Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetailsResponse> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange({
dateRange, dateRange,
new Date(startDateString) startDate: new Date(startDateString)
); });
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, 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 { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -32,6 +32,7 @@ import { BenchmarksService } from './benchmarks.service';
@Module({ @Module({
controllers: [BenchmarksController], controllers: [BenchmarksController],
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
@ -39,7 +40,6 @@ import { BenchmarksService } from './benchmarks.service';
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule, 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 { 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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.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'; 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 { export class PublicController {
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService 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', sortColumn: 'date',
sortDirection: 'desc', sortDirection: 'desc',
take: 10, take: 10,
@ -167,6 +167,7 @@ export class PublicController {
allocationInPercentage: allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue, portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource, 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 { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -27,13 +27,13 @@ import { PublicController } from './public.controller';
controllers: [PublicController], controllers: [PublicController],
imports: [ imports: [
AccessModule, AccessModule,
ActivitiesModule,
BenchmarkModule, BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,

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

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

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

@ -1,5 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.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 { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -17,8 +17,8 @@ import { groupBy, uniqBy } from 'lodash';
export class ExportService { export class ExportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService private readonly tagService: TagService
) {} ) {}
@ -38,7 +38,7 @@ export class ExportService {
}); });
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({ let { activities } = await this.activitiesService.getActivities({
filters, filters,
userId, userId,
includeDrafts: true, includeDrafts: true,

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

@ -38,7 +38,7 @@ export class ImportController {
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder) @HasPermission(permissions.createActivity)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( 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 { 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 { 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 { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -25,6 +25,7 @@ import { ImportService } from './import.service';
controllers: [ImportController], controllers: [ImportController],
imports: [ imports: [
AccountModule, AccountModule,
ActivitiesModule,
ApiModule, ApiModule,
CacheModule, CacheModule,
ConfigurationModule, ConfigurationModule,
@ -32,7 +33,6 @@ import { ImportService } from './import.service';
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,

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

@ -1,5 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.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 { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
@ -44,12 +44,12 @@ import { ImportDataDto } from './import-data.dto';
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -91,7 +91,7 @@ export class ImportService {
userId, userId,
withExcludedAccounts: true withExcludedAccounts: true
}), }),
this.orderService.getOrders({ this.activitiesService.getActivities({
filters, filters,
userCurrency, userCurrency,
userId, userId,
@ -548,7 +548,7 @@ export class ImportService {
continue; continue;
} }
order = await this.orderService.createOrder({ order = await this.activitiesService.createActivity({
comment, comment,
currency, currency,
date, date,
@ -645,7 +645,7 @@ export class ImportService {
userId: string; userId: string;
}): Promise<Partial<Activity>[]> { }): Promise<Partial<Activity>[]> {
const { activities: existingActivities } = const { activities: existingActivities } =
await this.orderService.getOrders({ await this.activitiesService.getActivities({
userCurrency, userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,

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

@ -158,10 +158,10 @@ export abstract class PortfolioCalculator {
this.redisCacheService = redisCacheService; this.redisCacheService = redisCacheService;
this.userId = userId; this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange({
'max', dateRange: 'max',
subDays(dateOfFirstActivity, 1) startDate: subDays(dateOfFirstActivity, 1)
); });
this.endDate = endOfDay(endDate); this.endDate = endOfDay(endDate);
this.startDate = startOfDay(startDate); this.startDate = startOfDay(startDate);
@ -885,7 +885,7 @@ export abstract class PortfolioCalculator {
// Make sure some key dates are present // Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } = const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange); getIntervalFromDateRange({ dateRange });
if ( if (
!isBefore(dateRangeStart, startDate) && !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 { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; 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', () => { describe('PortfolioCalculator', () => {
let accountBalanceService: AccountBalanceService; let accountBalanceService: AccountBalanceService;
let accountService: AccountService; let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let orderService: OrderService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
@ -106,13 +106,13 @@ describe('PortfolioCalculator', () => {
); );
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
dataProviderService,
null, null,
dataProviderService,
null, null,
null null
); );
orderService = new OrderService( activitiesService = new ActivitiesService(
accountBalanceService, accountBalanceService,
accountService, accountService,
null, null,
@ -183,18 +183,17 @@ describe('PortfolioCalculator', () => {
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') .spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO); .mockReturnValue(DataSource.YAHOO);
jest.spyOn(orderService, 'getOrders').mockResolvedValue({ jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [], activities: [],
count: 0 count: 0
}); });
const { activities } = await orderService.getOrdersForPortfolioCalculator( const { activities } =
{ await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF', userCurrency: 'CHF',
userId: userDummyData.id, userId: userDummyData.id,
withCash: true withCash: true
} });
);
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [], 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'); return format(date, 'yyyy');
}) })
] as DateRange[]) { ] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange); const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate; const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate; 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); marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
null,
dataProviderService, dataProviderService,
marketDataService, marketDataService,
null,
null null
); );
}); });

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

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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
@ -63,10 +63,10 @@ import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -187,7 +187,6 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds',
'currentNetWorth', 'currentNetWorth',
'currentValueInBaseCurrency', 'currentValueInBaseCurrency',
'dividendInBaseCurrency', 'dividendInBaseCurrency',
@ -321,9 +320,9 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; 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, endDate,
filters, filters,
startDate, startDate,
@ -640,7 +639,7 @@ export class PortfolioController {
return report; return report;
} }
@HasPermission(permissions.updateOrder) @HasPermission(permissions.updateActivity)
@Put('holding/:dataSource/:symbol/tags') @Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @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 { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module'; import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module';
@ -34,6 +34,7 @@ import { RulesService } from './rules.service';
exports: [PortfolioService], exports: [PortfolioService],
imports: [ imports: [
AccessModule, AccessModule,
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
@ -43,7 +44,6 @@ import { RulesService } from './rules.service';
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PerformanceLoggingModule, PerformanceLoggingModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, 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 { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; 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 { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -105,13 +105,13 @@ export class PortfolioService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -403,10 +403,10 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -490,7 +490,7 @@ export class PortfolioService {
); );
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -623,6 +623,28 @@ export class PortfolioService {
? 0 ? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass, 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, assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries, countries: assetProfile.countries,
dataSource: assetProfile.dataSource, dataSource: assetProfile.dataSource,
@ -758,7 +780,7 @@ export class PortfolioService {
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency, userCurrency,
userId userId
}); });
@ -988,7 +1010,7 @@ export class PortfolioService {
userId, userId,
userCurrency userCurrency
}), }),
this.orderService.getOrdersForPortfolioCalculator({ this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -1025,7 +1047,7 @@ export class PortfolioService {
const { errors, hasErrors, historicalData } = const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot(); await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({ const { chart } = await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
@ -1349,7 +1371,12 @@ export class PortfolioService {
}) { }) {
userId = await this.getUserId(impersonationId, userId); 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>): { private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
@ -1672,6 +1699,17 @@ export class PortfolioService {
allocationInPercentage: 0, allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH, assetSubClass: AssetSubClass.CASH,
assetProfile: {
currency,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
holdings: [],
name: currency,
sectors: [],
symbol: currency
},
countries: [], countries: [],
dataSource: undefined, dataSource: undefined,
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
@ -1841,7 +1879,7 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
@ -1914,8 +1952,6 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency) .plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({ const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency, userCurrency,
activities: excludedActivities, activities: excludedActivities,
@ -1979,7 +2015,6 @@ export class PortfolioService {
activityCount: activities.filter(({ type }) => { activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type); return ['BUY', 'SELL'].includes(type);
}).length, }).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: { 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 { Inject, Injectable, Logger } from '@nestjs/common';
import Keyv from 'keyv'; import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
import { createHash } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
@ -75,13 +75,16 @@ export class RedisCacheService {
} }
public async isHealthy() { 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(); const testValue = Date.now().toString();
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
await this.set(testKey, testValue, ms('1 second')); await this.set(testKey, testValue, HEALTH_CHECK_TIMEOUT);
const result = await this.get(testKey); const result = await this.get(testKey);
if (result !== testValue) { if (result !== testValue) {
@ -91,7 +94,7 @@ export class RedisCacheService {
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check failed: timeout')), () => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds') HEALTH_CHECK_TIMEOUT
) )
) )
]); ]);

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

14
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 { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
@ -55,10 +55,10 @@ import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -530,9 +530,15 @@ export class UserService {
} }
} }
if (!environment.production && hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
if (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) {
currentPermissions.push(permissions.accessAdminControlBullBoard);
}
if (!environment.production) {
currentPermissions.push(permissions.impersonateAllUsers); currentPermissions.push(permissions.impersonateAllUsers);
} }
}
user.accounts = user.accounts.sort((a, b) => { user.accounts = user.accounts.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
@ -643,7 +649,7 @@ export class UserService {
} catch {} } catch {}
try { try {
await this.orderService.deleteOrders({ await this.activitiesService.deleteActivities({
userId: where.id userId: where.id
}); });
} catch {} } catch {}

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

@ -8,4 +8,16 @@ export class AssetProfileChangedEvent {
public static getName(): string { public static getName(): string {
return 'assetProfile.changed'; 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event'; import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable() @Injectable()
export class AssetProfileChangedListener { export class AssetProfileChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService
private readonly orderService: OrderService
) {} ) {}
@OnEvent(AssetProfileChangedEvent.getName()) @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( Logger.log(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`, `Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -31,16 +76,16 @@ export class AssetProfileChangedListener {
this.configurationService.get( this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false || ) === false ||
event.data.currency === DEFAULT_CURRENCY currency === DEFAULT_CURRENCY
) { ) {
return; return;
} }
const existingCurrencies = this.exchangeRateDataService.getCurrencies(); const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) { if (!existingCurrencies.includes(currency)) {
Logger.log( Logger.log(
`New currency ${event.data.currency} has been detected`, `New currency ${currency} has been detected`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -48,13 +93,13 @@ export class AssetProfileChangedListener {
} }
const { dateOfFirstActivity } = const { dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(event.data.currency); await this.activitiesService.getStatisticsByCurrency(currency);
if (dateOfFirstActivity) { if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({ await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(), dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
date: dateOfFirstActivity, 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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -12,11 +12,11 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
OrderModule,
RedisCacheModule RedisCacheModule
], ],
providers: [AssetProfileChangedListener, PortfolioChangedListener] 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 { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import ms from 'ms';
import { PortfolioChangedEvent } from './portfolio-changed.event'; import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable() @Injectable()
export class PortfolioChangedListener { export class PortfolioChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName()) @OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) { 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( Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`, `Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener' 'PortfolioChangedListener'
); );
this.redisCacheService.removePortfolioSnapshotsByUserId({ await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
userId: event.getUserId()
});
} }
} }

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

@ -1540,7 +1540,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffect: null,
totalBuy: null, totalBuy: null,
totalSell: null, totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null, currentValueInBaseCurrency: null,
dividendInBaseCurrency: null, dividendInBaseCurrency: null,
emergencyFund: null, emergencyFund: null,
@ -3017,7 +3016,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffect: null,
totalBuy: null, totalBuy: null,
totalSell: null, totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null, currentValueInBaseCurrency: null,
dividendInBaseCurrency: null, dividendInBaseCurrency: null,
emergencyFund: 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', 'errors[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource', 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',
'fearAndGreedIndex.STOCKS.dataSource', 'fearAndGreedIndex.STOCKS.dataSource',
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource', 'holdings[*].dataSource',
'items[*].dataSource', 'items[*].dataSource',
'SymbolProfile.dataSource', 'SymbolProfile.dataSource',

6
apps/api/src/main.ts

@ -1,4 +1,5 @@
import { import {
BULL_BOARD_ROUTE,
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
STORYBOOK_PATH, STORYBOOK_PATH,
@ -14,6 +15,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
@ -46,6 +48,7 @@ async function bootstrap() {
}); });
app.setGlobalPrefix('api', { app.setGlobalPrefix('api', {
exclude: [ exclude: [
`${BULL_BOARD_ROUTE.substring(1)}{/*wildcard}`,
'sitemap.xml', 'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => { ...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
// Exclude language-specific routes with an optional wildcard // Exclude language-specific routes with an optional wildcard
@ -53,6 +56,7 @@ async function bootstrap() {
}) })
] ]
}); });
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -64,6 +68,8 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities // Support 10mb csv/json files for importing activities
app.useBodyParser('json', { limit: '10mb' }); app.useBodyParser('json', { limit: '10mb' });
app.use(cookieParser());
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) { 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_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
@ -43,6 +44,7 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), 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_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), 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 LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import Alphavantage from 'alphavantage'; import Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns'; 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'; import { AlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable() @Injectable()
export class AlphaVantageService implements DataProviderInterface { export class AlphaVantageService
implements DataProviderInterface, OnModuleInit
{
public alphaVantage; public alphaVantage;
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
this.alphaVantage = Alphavantage({ this.alphaVantage = Alphavantage({
key: this.configurationService.get('API_KEY_ALPHA_VANTAGE') 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 LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -27,13 +27,15 @@ import {
import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private readonly apiUrl: string; private apiUrl: string;
private readonly headers: HeadersInit = {}; private headers: HeadersInit = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO'); const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO'); 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'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -33,14 +33,18 @@ import { addDays, format, isSameDay, isToday } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit
{
private apiKey: string; private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) { ) {}
public onModuleInit() {
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA'); 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'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -44,7 +44,9 @@ import {
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = { private static countriesMapping = {
'Korea (the Republic of)': 'South Korea', 'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'Russia', 'Russian Federation': 'Russia',
@ -57,7 +59,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) { ) {}
public onModuleInit() {
this.apiKey = this.configurationService.get( this.apiKey = this.configurationService.get(
'API_KEY_FINANCIAL_MODELING_PREP' '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 { 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 * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'node:fs'; import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService { export class I18nService implements OnModuleInit {
private localesPath = join(__dirname, 'assets', 'locales'); private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {}; private translations: { [locale: string]: cheerio.CheerioAPI } = {};
public constructor() { public onModuleInit() {
this.loadFiles(); 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_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string; API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string; API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;
@ -19,6 +20,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_BULL_BOARD: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: 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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; 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 { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import ms from 'ms'; import ms from 'ms';
@ -17,6 +19,18 @@ import { DataGatheringProcessor } from './data-gathering.processor';
@Module({ @Module({
imports: [ 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({ BullModule.registerQueue({
limiter: { limiter: {
duration: ms('4 seconds'), 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 { 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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -13,6 +13,8 @@ import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -22,6 +24,19 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
exports: [BullModule, PortfolioSnapshotService], exports: [BullModule, PortfolioSnapshotService],
imports: [ imports: [
AccountBalanceModule, 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({ BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: { settings: {
@ -36,7 +51,6 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
RedisCacheModule RedisCacheModule
], ],
providers: [ 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 { 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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; 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 { export class PortfolioSnapshotProcessor {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) {} ) {}
@ -47,7 +47,7 @@ export class PortfolioSnapshotProcessor {
); );
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters: job.data.filters, filters: job.data.filters,
userCurrency: job.data.userCurrency, userCurrency: job.data.userCurrency,
userId: job.data.userId, userId: job.data.userId,

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

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

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

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

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

@ -26,11 +26,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef,
Inject, Inject,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
@ -49,8 +50,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin, Subject } from 'rxjs'; import { forkJoin } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import { AccountDetailDialogParams } from './interfaces/interfaces';
@ -77,7 +77,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'], styleUrls: ['./account-detail-dialog.component.scss'],
templateUrl: 'account-detail-dialog.html' templateUrl: 'account-detail-dialog.html'
}) })
export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { export class GfAccountDetailDialogComponent implements OnInit {
public accountBalances: AccountBalancesResponse['balances']; public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[]; public activities: OrderWithAccount[];
public activitiesCount: number; public activitiesCount: number;
@ -104,18 +104,17 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public user: User; public user: User;
public valueInBaseCurrency: number; public valueInBaseCurrency: number;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>, public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>,
private router: Router, private router: Router,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -154,7 +153,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService this.dataService
.postAccountBalance(accountBalance) .postAccountBalance(accountBalance)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
}); });
@ -163,7 +162,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public onDeleteAccountBalance(aId: string) { public onDeleteAccountBalance(aId: string) {
this.dataService this.dataService
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
}); });
@ -176,7 +175,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchExport({ activityIds }) .fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({
content: data, content: data,
@ -212,7 +211,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
private fetchAccount() { private fetchAccount() {
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe(
({ ({
activitiesCount, activitiesCount,
@ -287,7 +286,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection sortDirection: this.sortDirection
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => { .subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
this.totalItems = count; this.totalItems = count;
@ -304,7 +303,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
forkJoin({ forkJoin({
accountBalances: this.dataService accountBalances: this.dataService
.fetchAccountBalances(this.data.accountId) .fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)), .pipe(takeUntilDestroyed(this.destroyRef)),
portfolioPerformance: this.dataService portfolioPerformance: this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: [ filters: [
@ -317,7 +316,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
withExcludedAccounts: true, withExcludedAccounts: true,
withItems: true withItems: true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
}).subscribe({ }).subscribe({
error: () => { error: () => {
this.isLoadingChart = false; this.isLoadingChart = false;
@ -360,7 +359,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
} }
] ]
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = holdings; this.holdings = holdings;
@ -374,9 +373,4 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.fetchChart(); this.fetchChart();
this.fetchPortfolioHoldings(); 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -7,6 +10,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces'; import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
@ -15,10 +19,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -41,6 +46,7 @@ import {
chevronUpCircleOutline, chevronUpCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
ellipsisVertical, ellipsisVertical,
openOutline,
pauseOutline, pauseOutline,
playOutline, playOutline,
removeCircleOutline, removeCircleOutline,
@ -48,8 +54,6 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { get } from 'lodash'; import { get } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -69,7 +73,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./admin-jobs.scss'], styleUrls: ['./admin-jobs.scss'],
templateUrl: './admin-jobs.html' templateUrl: './admin-jobs.html'
}) })
export class GfAdminJobsComponent implements OnDestroy, OnInit { export class GfAdminJobsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW; 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 dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string; public defaultDateTimeFormat: string;
public filterForm: FormGroup; public filterForm: FormGroup;
public displayedColumns = [ public displayedColumns = [
'index', 'index',
'type', 'type',
@ -93,21 +98,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
'status', 'status',
'actions' 'actions'
]; ];
public hasPermissionToAccessBullBoard = false;
public isLoading = false; public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST; public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
private unsubscribeSubject = new Subject<void>(); private user: User;
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private tokenStorageService: TokenStorageService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -115,6 +123,11 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString( this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale this.user.settings.locale
); );
this.hasPermissionToAccessBullBoard = hasPermission(
this.user.permissions,
permissions.accessAdminControlBullBoard
);
} }
}); });
@ -126,6 +139,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
chevronUpCircleOutline, chevronUpCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
ellipsisVertical, ellipsisVertical,
openOutline,
pauseOutline, pauseOutline,
playOutline, playOutline,
removeCircleOutline, removeCircleOutline,
@ -139,7 +153,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
}); });
this.filterForm.valueChanges this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
const currentFilter = this.filterForm.get('status').value; const currentFilter = this.filterForm.get('status').value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
@ -151,7 +165,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onDeleteJob(aId: string) { public onDeleteJob(aId: string) {
this.adminService this.adminService
.deleteJob(aId) .deleteJob(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(); this.fetchJobs();
}); });
@ -162,7 +176,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.adminService this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined }) .deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
}); });
@ -171,12 +185,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onExecuteJob(aId: string) { public onExecuteJob(aId: string) {
this.adminService this.adminService
.executeJob(aId) .executeJob(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(); 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']) { public onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aData, null, ' ') 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[]) { private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true; this.isLoading = true;
this.adminService this.adminService
.fetchJobs({ status: aStatus }) .fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ jobs }) => { .subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs); this.dataSource = new MatTableDataSource(jobs);
this.dataSource.sort = this.sort; 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="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@if (hasPermissionToAccessBullBoard) {
<div class="d-flex justify-content-end mb-3">
<button mat-stroked-button (click)="onOpenBullBoard()">
<span><ng-container i18n>Overview</ng-container></span>
<ion-icon class="ml-2" name="open-outline" />
</button>
</div>
}
<form class="align-items-center d-flex" [formGroup]="filterForm"> <form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status"> <mat-select formControlName="status">

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

@ -26,10 +26,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@ -63,7 +64,7 @@ import {
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service'; import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component'; 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'], styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'
}) })
export class GfAdminMarketDataComponent export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
implements AfterViewInit, OnDestroy, OnInit
{
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
@ -166,13 +165,12 @@ export class GfAdminMarketDataComponent
public totalItems = 0; public totalItems = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public adminMarketDataService: AdminMarketDataService, public adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private route: ActivatedRoute, private route: ActivatedRoute,
@ -209,7 +207,7 @@ export class GfAdminMarketDataComponent
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if ( if (
params['assetProfileDialog'] && params['assetProfileDialog'] &&
@ -226,7 +224,7 @@ export class GfAdminMarketDataComponent
}); });
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -238,7 +236,7 @@ export class GfAdminMarketDataComponent
}); });
this.filters$ this.filters$
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject)) .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((filters) => { .subscribe((filters) => {
this.activeFilters = filters; this.activeFilters = filters;
@ -302,7 +300,7 @@ export class GfAdminMarketDataComponent
public onGather7Days() { public onGather7Days() {
this.adminService this.adminService
.gather7Days() .gather7Days()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
@ -313,7 +311,7 @@ export class GfAdminMarketDataComponent
public onGatherMax() { public onGatherMax() {
this.adminService this.adminService
.gatherMax() .gatherMax()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
@ -324,7 +322,7 @@ export class GfAdminMarketDataComponent
public onGatherProfileData() { public onGatherProfileData() {
this.adminService this.adminService
.gatherProfileData() .gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
@ -334,14 +332,14 @@ export class GfAdminMarketDataComponent
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
@ -358,11 +356,6 @@ export class GfAdminMarketDataComponent
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadData( private loadData(
{ {
pageIndex, pageIndex,
@ -399,7 +392,7 @@ export class GfAdminMarketDataComponent
skip: pageIndex * this.pageSize, skip: pageIndex * this.pageSize,
take: this.pageSize take: this.pageSize
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, marketData }) => { .subscribe(({ count, marketData }) => {
this.totalItems = count; this.totalItems = count;
@ -430,7 +423,7 @@ export class GfAdminMarketDataComponent
}) { }) {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
@ -452,7 +445,7 @@ export class GfAdminMarketDataComponent
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe(
(newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => { (newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => {
if (newAssetProfileIdentifier) { if (newAssetProfileIdentifier) {
@ -468,7 +461,7 @@ export class GfAdminMarketDataComponent
private openCreateAssetProfileDialog() { private openCreateAssetProfileDialog() {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
@ -486,7 +479,7 @@ export class GfAdminMarketDataComponent
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => { .subscribe((result) => {
if (!result) { if (!result) {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
@ -499,7 +492,7 @@ export class GfAdminMarketDataComponent
if (addAssetProfile && dataSource && symbol) { if (addAssetProfile && dataSource && symbol) {
this.adminService this.adminService
.addAssetProfile({ dataSource, symbol }) .addAssetProfile({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.loadData(); 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, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef,
ElementRef, ElementRef,
Inject, Inject,
OnDestroy,
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
AbstractControl, AbstractControl,
FormBuilder, FormBuilder,
@ -87,8 +88,8 @@ import {
serverOutline serverOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces'; import { AssetProfileDialogParams } from './interfaces/interfaces';
@ -121,7 +122,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss'], styleUrls: ['./asset-profile-dialog.component.scss'],
templateUrl: 'asset-profile-dialog.html' 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( private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(), new Date(),
DATE_FORMAT DATE_FORMAT
@ -241,14 +242,13 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public adminMarketDataService: AdminMarketDataService, public adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAssetProfileDialogComponent>, public dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
@ -282,7 +282,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ settings }) => { .subscribe(({ settings }) => {
this.isDataGatheringEnabled = this.isDataGatheringEnabled =
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true; settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true;
@ -291,7 +291,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}); });
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -300,7 +300,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
this.assetProfileForm this.assetProfileForm
.get('assetClass') .get('assetClass')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((assetClass) => { .subscribe((assetClass) => {
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? []; const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
@ -323,7 +323,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
@ -436,7 +436,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
@ -449,7 +449,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
} & AssetProfileIdentifier) { } & AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, range, symbol }) .gatherSymbol({ dataSource, range, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
@ -462,7 +462,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.postBenchmark({ dataSource, symbol }) .postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.dataService.updateInfo(); this.dataService.updateInfo();
@ -664,7 +664,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
const newAssetProfileIdentifier = { const newAssetProfileIdentifier = {
@ -714,7 +714,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}); });
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ price }) => { .subscribe(({ price }) => {
this.notificationService.alert({ this.notificationService.alert({
@ -745,7 +745,7 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
this.dataService this.dataService
.deleteBenchmark({ dataSource, symbol }) .deleteBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.dataService.updateInfo(); this.dataService.updateInfo();
@ -755,11 +755,6 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
public onTriggerSubmitAssetProfileForm() { public onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm.valid) { if (this.assetProfileForm.valid) {
this.onSubmitAssetProfileForm(); 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, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
AbstractControl, AbstractControl,
FormBuilder, FormBuilder,
@ -31,7 +32,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode } from 'class-validator';
import { Subject, switchMap, takeUntil } from 'rxjs'; import { switchMap } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces'; import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@ -52,19 +53,19 @@ import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
styleUrls: ['./create-asset-profile-dialog.component.scss'], styleUrls: ['./create-asset-profile-dialog.component.scss'],
templateUrl: 'create-asset-profile-dialog.html' templateUrl: 'create-asset-profile-dialog.html'
}) })
export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit { export class GfCreateAssetProfileDialogComponent implements OnInit {
public createAssetProfileForm: FormGroup; public createAssetProfileForm: FormGroup;
public ghostfolioPrefix = `${ghostfolioPrefix}_`; public ghostfolioPrefix = `${ghostfolioPrefix}_`;
public mode: CreateAssetProfileDialogMode; public mode: CreateAssetProfileDialogMode;
private customCurrencies: string[]; private customCurrencies: string[];
private dataSourceForExchangeRates: DataSource; private dataSourceForExchangeRates: DataSource;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public readonly adminService: AdminService, public readonly adminService: AdminService,
private readonly changeDetectorRef: ChangeDetectorRef, private readonly changeDetectorRef: ChangeDetectorRef,
private readonly dataService: DataService, private readonly dataService: DataService,
private readonly destroyRef: DestroyRef,
public readonly dialogRef: MatDialogRef<GfCreateAssetProfileDialogComponent>, public readonly dialogRef: MatDialogRef<GfCreateAssetProfileDialogComponent>,
public readonly formBuilder: FormBuilder public readonly formBuilder: FormBuilder
) {} ) {}
@ -125,7 +126,7 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
symbol: `${DEFAULT_CURRENCY}${currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}); });
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
this.dialogRef.close({ this.dialogRef.close({
@ -154,11 +155,6 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
return false; return false;
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private atLeastOneValid(control: AbstractControl): ValidationErrors { private atLeastOneValid(control: AbstractControl): ValidationErrors {
const addCurrencyControl = control.get('addCurrency'); const addCurrencyControl = control.get('addCurrency');
const addSymbolControl = control.get('addSymbol'); const addSymbolControl = control.get('addSymbol');
@ -189,7 +185,7 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
private initialize() { private initialize() {
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataProviders, settings }) => { .subscribe(({ dataProviders, settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; 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 { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; 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 { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
@ -50,8 +56,6 @@ import {
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
imports: [ imports: [
@ -72,7 +76,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./admin-overview.scss'], styleUrls: ['./admin-overview.scss'],
templateUrl: './admin-overview.html' templateUrl: './admin-overview.html'
}) })
export class GfAdminOverviewComponent implements OnDestroy, OnInit { export class GfAdminOverviewComponent implements OnInit {
public activitiesCount: number; public activitiesCount: number;
public couponDuration: StringValue = '14 days'; public couponDuration: StringValue = '14 days';
public coupons: Coupon[]; public coupons: Coupon[];
@ -88,13 +92,12 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
public user: User; public user: User;
public version: string; public version: string;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private cacheService: CacheService, private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
@ -102,7 +105,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -219,7 +222,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
confirmFn: () => { confirmFn: () => {
this.cacheService this.cacheService
.flush() .flush()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
@ -268,7 +271,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
public onSyncDemoUserAccount() { public onSyncDemoUserAccount() {
this.adminService this.adminService
.syncDemoUserAccount() .syncDemoUserAccount()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.snackBar.open( this.snackBar.open(
'✅ ' + $localize`Demo user account has been synced.`, '✅ ' + $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() { private fetchAdminData() {
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activitiesCount, settings, userCount, version }) => { .subscribe(({ activitiesCount, settings, userCount, version }) => {
this.activitiesCount = activitiesCount; this.activitiesCount = activitiesCount;
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
@ -320,7 +318,7 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
.putAdminSetting(key, { .putAdminSetting(key, {
value: value || value === false ? JSON.stringify(value) : undefined value: value || value === false ? JSON.stringify(value) : undefined
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { setTimeout(() => {
window.location.reload(); 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> <ng-container i18n>Accounts</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <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> </td>
</ng-container> </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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService, DataService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
Input,
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -29,7 +33,6 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { get } from 'lodash'; import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; 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 { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-dialog/interfaces/interfaces'; import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-dialog/interfaces/interfaces';
@ -38,6 +41,7 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfEntityLogoComponent, GfEntityLogoComponent,
GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
@ -49,7 +53,9 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
styleUrls: ['./admin-platform.component.scss'], styleUrls: ['./admin-platform.component.scss'],
templateUrl: './admin-platform.component.html' templateUrl: './admin-platform.component.html'
}) })
export class GfAdminPlatformComponent implements OnDestroy, OnInit { export class GfAdminPlatformComponent implements OnInit {
@Input() locale = getLocale();
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<Platform>(); public dataSource = new MatTableDataSource<Platform>();
@ -57,12 +63,11 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
public displayedColumns = ['name', 'url', 'accounts', 'actions']; public displayedColumns = ['name', 'url', 'accounts', 'actions'];
public platforms: Platform[]; public platforms: Platform[];
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private notificationService: NotificationService, private notificationService: NotificationService,
@ -71,7 +76,7 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
private userService: UserService private userService: UserService
) { ) {
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if (params['createPlatformDialog']) { if (params['createPlatformDialog']) {
this.openCreatePlatformDialog(); this.openCreatePlatformDialog();
@ -113,20 +118,15 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deletePlatform(aId: string) { private deletePlatform(aId: string) {
this.adminService this.adminService
.deletePlatform(aId) .deletePlatform(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchPlatforms(); this.fetchPlatforms();
@ -137,7 +137,7 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
private fetchPlatforms() { private fetchPlatforms() {
this.adminService this.adminService
.fetchPlatforms() .fetchPlatforms()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((platforms) => { .subscribe((platforms) => {
this.platforms = platforms; this.platforms = platforms;
@ -169,17 +169,17 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((platform: CreatePlatformDto | null) => { .subscribe((platform: CreatePlatformDto | null) => {
if (platform) { if (platform) {
this.adminService this.adminService
.postPlatform(platform) .postPlatform(platform)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchPlatforms(); this.fetchPlatforms();
@ -217,17 +217,17 @@ export class GfAdminPlatformComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((platform: UpdatePlatformDto | null) => { .subscribe((platform: UpdatePlatformDto | null) => {
if (platform) { if (platform) {
this.adminService this.adminService
.putPlatform(platform) .putPlatform(platform)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchPlatforms(); 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 { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -23,7 +18,6 @@ import {
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { Subject } from 'rxjs';
import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces'; import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
@ -43,11 +37,9 @@ import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-platform-dialog.scss'], styleUrls: ['./create-or-update-platform-dialog.scss'],
templateUrl: 'create-or-update-platform-dialog.html' templateUrl: 'create-or-update-platform-dialog.html'
}) })
export class GfCreateOrUpdatePlatformDialogComponent implements OnDestroy { export class GfCreateOrUpdatePlatformDialogComponent {
public platformForm: FormGroup; public platformForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdatePlatformDialogComponent>, public dialogRef: MatDialogRef<GfCreateOrUpdatePlatformDialogComponent>,
@ -90,9 +82,4 @@ export class GfCreateOrUpdatePlatformDialogComponent implements OnDestroy {
console.error(error); 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-actions>
</mat-card> </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"> <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> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@ -102,7 +114,12 @@
</ng-container> </ng-container>
<ng-container matColumnDef="assetProfileCount"> <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> <ng-container i18n>Asset Profiles</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -193,13 +210,13 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Platforms</h2> <h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform /> <gf-admin-platform [locale]="user?.settings?.locale" />
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Tags</h2> <h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag /> <gf-admin-tag [locale]="user?.settings?.locale" />
</div> </div>
</div> </div>
</div> </div>

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

@ -22,20 +22,24 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
OnInit OnInit,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { ellipsisHorizontal, trashOutline } from 'ionicons/icons'; import { ellipsisHorizontal, trashOutline } from 'ionicons/icons';
import { get } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, filter, of, Subject, takeUntil } from 'rxjs'; import { catchError, filter, of } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -52,6 +56,7 @@ import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
MatCardModule, MatCardModule,
MatMenuModule, MatMenuModule,
MatProgressBarModule, MatProgressBarModule,
MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
RouterModule RouterModule
@ -60,7 +65,9 @@ import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
styleUrls: ['./admin-settings.component.scss'], styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html' 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 dataSource = new MatTableDataSource<DataProviderInfo>();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns = [ public displayedColumns = [
@ -74,14 +81,13 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
public isGhostfolioApiKeyValid: boolean; public isGhostfolioApiKeyValid: boolean;
public isLoading = false; public isLoading = false;
public pricingUrl: string; public pricingUrl: string;
public user: User;
private unsubscribeSubject = new Subject<void>();
private user: User;
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService, private notificationService: NotificationService,
private userService: UserService private userService: UserService
) { ) {
@ -90,7 +96,7 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = 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() { private initialize() {
this.isLoading = true; this.isLoading = true;
@ -159,13 +160,15 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dataProviders, settings }) => { .subscribe(({ dataProviders, settings }) => {
const filteredProviders = dataProviders.filter(({ dataSource }) => { const filteredProviders = dataProviders.filter(({ dataSource }) => {
return dataSource !== 'MANUAL'; return dataSource !== 'MANUAL';
}); });
this.dataSource = new MatTableDataSource(filteredProviders); this.dataSource = new MatTableDataSource(filteredProviders);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
const ghostfolioApiKey = settings[ const ghostfolioApiKey = settings[
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
@ -185,7 +188,7 @@ export class GfAdminSettingsComponent implements OnDestroy, OnInit {
filter((status) => { filter((status) => {
return status !== null; return status !== null;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe((status) => { .subscribe((status) => {
this.ghostfolioApiStatus = 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> <ng-container i18n>Activities</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <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> </td>
</ng-container> </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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
Input,
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -28,7 +32,6 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { get } from 'lodash'; import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; 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 { GfCreateOrUpdateTagDialogComponent } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/interfaces/interfaces'; import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/interfaces/interfaces';
@ -36,6 +39,7 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
@ -47,7 +51,9 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
styleUrls: ['./admin-tag.component.scss'], styleUrls: ['./admin-tag.component.scss'],
templateUrl: './admin-tag.component.html' templateUrl: './admin-tag.component.html'
}) })
export class GfAdminTagComponent implements OnDestroy, OnInit { export class GfAdminTagComponent implements OnInit {
@Input() locale = getLocale();
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<Tag>(); public dataSource = new MatTableDataSource<Tag>();
@ -55,11 +61,10 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
public displayedColumns = ['name', 'userId', 'activities', 'actions']; public displayedColumns = ['name', 'userId', 'activities', 'actions'];
public tags: Tag[]; public tags: Tag[];
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private notificationService: NotificationService, private notificationService: NotificationService,
@ -68,7 +73,7 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
private userService: UserService private userService: UserService
) { ) {
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if (params['createTagDialog']) { if (params['createTagDialog']) {
this.openCreateTagDialog(); this.openCreateTagDialog();
@ -110,20 +115,15 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deleteTag(aId: string) { private deleteTag(aId: string) {
this.dataService this.dataService
.deleteTag(aId) .deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchTags(); this.fetchTags();
@ -134,7 +134,7 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
private fetchTags() { private fetchTags() {
this.dataService this.dataService
.fetchTags() .fetchTags()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags) => { .subscribe((tags) => {
this.tags = tags; this.tags = tags;
@ -165,17 +165,17 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag: CreateTagDto | null) => { .subscribe((tag: CreateTagDto | null) => {
if (tag) { if (tag) {
this.dataService this.dataService
.postTag(tag) .postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchTags(); this.fetchTags();
@ -204,17 +204,17 @@ export class GfAdminTagComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag: UpdateTagDto | null) => { .subscribe((tag: UpdateTagDto | null) => {
if (tag) { if (tag) {
this.dataService this.dataService
.putTag(tag) .putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchTags(); 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 { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { validateObjectForForm } from '@ghostfolio/common/utils'; import { validateObjectForForm } from '@ghostfolio/common/utils';
import { import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -21,7 +16,6 @@ import {
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { Subject } from 'rxjs';
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
@ -40,11 +34,9 @@ import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-tag-dialog.scss'], styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html' templateUrl: 'create-or-update-tag-dialog.html'
}) })
export class GfCreateOrUpdateTagDialogComponent implements OnDestroy { export class GfCreateOrUpdateTagDialogComponent {
public tagForm: FormGroup; public tagForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdateTagDialogComponent>, public dialogRef: MatDialogRef<GfCreateOrUpdateTagDialogComponent>,
@ -85,9 +77,4 @@ export class GfCreateOrUpdateTagDialogComponent implements OnDestroy {
console.error(error); 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 { UserDetailDialogParams } from '@ghostfolio/client/components/user-detail-dialog/interfaces/interfaces';
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component'; import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
@ -26,10 +25,11 @@ import { CommonModule } from '@angular/common';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -56,8 +56,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { switchMap, tap } from 'rxjs/operators';
import { switchMap, takeUntil, tap } from 'rxjs/operators';
@Component({ @Component({
imports: [ imports: [
@ -76,7 +75,7 @@ import { switchMap, takeUntil, tap } from 'rxjs/operators';
styleUrls: ['./admin-users.scss'], styleUrls: ['./admin-users.scss'],
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class GfAdminUsersComponent implements OnDestroy, OnInit { export class GfAdminUsersComponent implements OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatPaginator) paginator: MatPaginator;
public dataSource = new MatTableDataSource<AdminUsersResponse['users'][0]>(); public dataSource = new MatTableDataSource<AdminUsersResponse['users'][0]>();
@ -90,23 +89,21 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
public isLoading = false; public isLoading = false;
public pageSize = DEFAULT_PAGE_SIZE; public pageSize = DEFAULT_PAGE_SIZE;
public routerLinkAdminControlUsers = public routerLinkAdminControlUsers =
internalRoutes.adminControl.subRoutes.users.routerLink; internalRoutes.adminControl.subRoutes?.users.routerLink;
public totalItems = 0; public totalItems = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService, private notificationService: NotificationService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService,
private userService: UserService private userService: UserService
) { ) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
@ -141,7 +138,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
this.userService.stateChanged this.userService.stateChanged
.pipe( .pipe(
takeUntil(this.unsubscribeSubject), takeUntilDestroyed(this.destroyRef),
tap((state) => { tap((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -206,7 +203,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
.deleteUser(aId) .deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.router.navigate(['..'], { relativeTo: this.route }); this.router.navigate(['..'], { relativeTo: this.route });
}); });
@ -224,13 +221,12 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
.updateUserAccessToken(aUserId) .updateUserAccessToken(aUserId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accessToken }) => { .subscribe(({ accessToken }) => {
this.notificationService.alert({ this.notificationService.alert({
discardFn: () => { discardFn: () => {
if (aUserId === this.user.id) { if (aUserId === this.user.id) {
this.tokenStorageService.signOut(); this.userService.signOut();
this.userService.remove();
document.location.href = `/${document.documentElement.lang}`; 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 }) { private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) {
this.isLoading = true; this.isLoading = true;
@ -278,7 +269,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
skip: pageIndex * this.pageSize, skip: pageIndex * this.pageSize,
take: this.pageSize take: this.pageSize
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, users }) => { .subscribe(({ count, users }) => {
this.dataSource = new MatTableDataSource(users); this.dataSource = new MatTableDataSource(users);
this.totalItems = count; this.totalItems = count;
@ -308,7 +299,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
if (data?.action === 'delete' && data?.userId) { if (data?.action === 'delete' && data?.userId) {
this.onDeleteUser(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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
DestroyRef,
Input, Input,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { DataSource } from '@prisma/client'; import type { DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; 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'; import { DataProviderStatus } from './interfaces/interfaces';
@ -20,14 +21,15 @@ import { DataProviderStatus } from './interfaces/interfaces';
selector: 'gf-data-provider-status', selector: 'gf-data-provider-status',
templateUrl: './data-provider-status.component.html' templateUrl: './data-provider-status.component.html'
}) })
export class GfDataProviderStatusComponent implements OnDestroy, OnInit { export class GfDataProviderStatusComponent implements OnInit {
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
public status$: Observable<DataProviderStatus>; public status$: Observable<DataProviderStatus>;
private unsubscribeSubject = new Subject<void>(); public constructor(
private dataService: DataService,
public constructor(private dataService: DataService) {} private destroyRef: DestroyRef
) {}
public ngOnInit() { public ngOnInit() {
this.status$ = this.dataService this.status$ = this.dataService
@ -39,12 +41,7 @@ export class GfDataProviderStatusComponent implements OnDestroy, OnInit {
catchError(() => { catchError(() => {
return of({ isHealthy: false }); 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 hasPermissionToAccessFearAndGreedIndex: boolean;
public routerLinkAbout = publicRoutes.about.routerLink; public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog = public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink; publicRoutes.about.subRoutes?.changelog.routerLink;
public routerLinkAboutLicense = public routerLinkAboutLicense =
publicRoutes.about.subRoutes.license.routerLink; publicRoutes.about.subRoutes?.license.routerLink;
public routerLinkAboutPrivacyPolicy = public routerLinkAboutPrivacyPolicy =
publicRoutes.about.subRoutes.privacyPolicy.routerLink; publicRoutes.about.subRoutes?.privacyPolicy.routerLink;
public routerLinkAboutTermsOfService = public routerLinkAboutTermsOfService =
publicRoutes.about.subRoutes.termsOfService.routerLink; publicRoutes.about.subRoutes?.termsOfService.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink; public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFaq = publicRoutes.faq.routerLink; public routerLinkFaq = publicRoutes.faq.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;

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

@ -24,6 +24,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
EventEmitter, EventEmitter,
HostListener, HostListener,
Input, Input,
@ -31,6 +32,7 @@ import {
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@ -48,8 +50,8 @@ import {
radioButtonOffOutline, radioButtonOffOutline,
radioButtonOnOutline radioButtonOnOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -131,10 +133,9 @@ export class GfHeaderComponent implements OnChanges {
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink; public routerLinkResources = publicRoutes.resources.routerLink;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService, private layoutService: LayoutService,
@ -146,7 +147,7 @@ export class GfHeaderComponent implements OnChanges {
) { ) {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId; this.impersonationId = impersonationId;
@ -224,11 +225,11 @@ export class GfHeaderComponent implements OnChanges {
public onDateRangeChange(dateRange: DateRange) { public onDateRangeChange(dateRange: DateRange) {
this.dataService this.dataService
.putUserSetting({ dateRange }) .putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
}); });
} }
@ -252,11 +253,11 @@ export class GfHeaderComponent implements OnChanges {
this.dataService this.dataService
.putUserSetting(userSetting) .putUserSetting(userSetting)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
}); });
} }
@ -301,7 +302,7 @@ export class GfHeaderComponent implements OnChanges {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
if (data?.accessToken) { if (data?.accessToken) {
this.dataService this.dataService
@ -314,7 +315,7 @@ export class GfHeaderComponent implements OnChanges {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ authToken }) => { .subscribe(({ authToken }) => {
this.setToken(authToken); this.setToken(authToken);
@ -331,7 +332,7 @@ export class GfHeaderComponent implements OnChanges {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
const userLanguage = user?.settings?.language; 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, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef,
Inject, Inject,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
@ -67,8 +68,7 @@ import {
walletOutline walletOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { switchMap } from 'rxjs/operators';
import { switchMap, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -102,7 +102,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./holding-detail-dialog.component.scss'], styleUrls: ['./holding-detail-dialog.component.scss'],
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { export class GfHoldingDetailDialogComponent implements OnInit {
public activitiesCount: number; public activitiesCount: number;
public accounts: Account[]; public accounts: Account[];
public assetClass: string; public assetClass: string;
@ -158,11 +158,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public user: User; public user: User;
public value: number; public value: number;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>, public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@ -192,7 +191,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.holdingForm this.holdingForm
.get('tags') .get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => { .subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => { const newTag = tags.find(({ id }) => {
return id === undefined; return id === undefined;
@ -217,7 +216,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
switchMap(() => { switchMap(() => {
return this.userService.get(true); return this.userService.get(true);
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(); .subscribe();
} else { } else {
@ -227,7 +226,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
}); });
@ -236,7 +235,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.fetchAccounts({ .fetchAccounts({
filters filters
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accounts }) => { .subscribe(({ accounts }) => {
this.accounts = accounts; this.accounts = accounts;
@ -249,7 +248,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection sortDirection: this.sortDirection
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => { .subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
@ -261,7 +260,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe(
({ ({
activitiesCount, activitiesCount,
@ -524,7 +523,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
); );
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -581,8 +580,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}; };
this.dataService this.dataService
.postOrder(activity) .postActivity(activity)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink internalRoutes.portfolio.subRoutes.activities.routerLink
@ -599,7 +598,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchExport({ activityIds }) .fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({
content: data, content: data,
@ -629,18 +628,13 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchMarketData() { private fetchMarketData() {
this.dataService this.dataService
.fetchMarketDataBySymbol({ .fetchMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ marketData }) => { .subscribe(({ marketData }) => {
this.marketDataItems = marketData; this.marketDataItems = marketData;

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

@ -414,7 +414,7 @@
<gf-tags-selector <gf-tags-selector
formControlName="tags" formControlName="tags"
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag" [hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[readonly]="!data.hasPermissionToUpdateOrder" [readonly]="!data.hasPermissionToUpdateActivity"
[tagsAvailable]="tagsAvailable" [tagsAvailable]="tagsAvailable"
/> />
</form> </form>

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

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

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

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

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

@ -17,12 +17,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
imports: [ imports: [
@ -35,7 +34,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./home-market.scss'], styleUrls: ['./home-market.scss'],
templateUrl: './home-market.html' templateUrl: './home-market.html'
}) })
export class GfHomeMarketComponent implements OnDestroy, OnInit { export class GfHomeMarketComponent implements OnInit {
public benchmarks: Benchmark[]; public benchmarks: Benchmark[];
public deviceType: string; public deviceType: string;
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
@ -47,11 +46,10 @@ export class GfHomeMarketComponent implements OnDestroy, OnInit {
public readonly numberOfDays = 365; public readonly numberOfDays = 365;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
@ -59,7 +57,7 @@ export class GfHomeMarketComponent implements OnDestroy, OnInit {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -82,7 +80,7 @@ export class GfHomeMarketComponent implements OnDestroy, OnInit {
includeHistoricalData: this.numberOfDays, includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol symbol: ghostfolioFearAndGreedIndexSymbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ historicalData, marketPrice }) => { .subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
this.historicalDataItems = [ this.historicalDataItems = [
@ -99,16 +97,11 @@ export class GfHomeMarketComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchBenchmarks() .fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => { .subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck(); 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, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
imports: [ imports: [
@ -41,7 +40,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./home-overview.scss'], styleUrls: ['./home-overview.scss'],
templateUrl: './home-overview.html' templateUrl: './home-overview.html'
}) })
export class GfHomeOverviewComponent implements OnDestroy, OnInit { export class GfHomeOverviewComponent implements OnInit {
public deviceType: string; public deviceType: string;
public errors: AssetProfileIdentifier[]; public errors: AssetProfileIdentifier[];
public hasError: boolean; public hasError: boolean;
@ -57,30 +56,29 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
public routerLinkAccounts = internalRoutes.accounts.routerLink; public routerLinkAccounts = internalRoutes.accounts.routerLink;
public routerLinkPortfolio = internalRoutes.portfolio.routerLink; public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public routerLinkPortfolioActivities = public routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink; internalRoutes.portfolio.subRoutes?.activities.routerLink;
public showDetails = false; public showDetails = false;
public unit: string; public unit: string;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateActivity = hasPermission( this.hasPermissionToCreateActivity = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createOrder permissions.createActivity
); );
this.update(); this.update();
@ -99,7 +97,7 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
@ -107,17 +105,12 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
}); });
this.layoutService.shouldReloadContent$ this.layoutService.shouldReloadContent$
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.update(); this.update();
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private update() { private update() {
this.historicalDataItems = null; this.historicalDataItems = null;
this.isLoadingPerformance = true; this.isLoadingPerformance = true;
@ -126,7 +119,7 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, errors, performance }) => { .subscribe(({ chart, errors, performance }) => {
this.errors = errors; this.errors = errors;
this.performance = performance; this.performance = performance;

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

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

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

@ -19,12 +19,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -39,7 +38,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./markets.scss'], styleUrls: ['./markets.scss'],
templateUrl: './markets.html' templateUrl: './markets.html'
}) })
export class GfMarketsComponent implements OnDestroy, OnInit { export class GfMarketsComponent implements OnInit {
public benchmarks: Benchmark[]; public benchmarks: Benchmark[];
public deviceType: string; public deviceType: string;
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
@ -55,18 +54,17 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public readonly numberOfDays = 365; public readonly numberOfDays = 365;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -79,7 +77,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.dataService this.dataService
.fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays }) .fetchMarketDataOfMarkets({ includeHistoricalData: this.numberOfDays })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ fearAndGreedIndex }) => { .subscribe(({ fearAndGreedIndex }) => {
this.fearAndGreedIndexData = fearAndGreedIndex; this.fearAndGreedIndexData = fearAndGreedIndex;
@ -90,7 +88,7 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchBenchmarks() .fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => { .subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
@ -119,9 +117,4 @@ export class GfMarketsComponent implements OnDestroy, OnInit {
this.initialize(); 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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
DestroyRef,
EventEmitter, EventEmitter,
Input, Input,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -29,7 +31,6 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject, takeUntil } from 'rxjs';
import { RuleSettingsDialogParams } from './rule-settings-dialog/interfaces/interfaces'; import { RuleSettingsDialogParams } from './rule-settings-dialog/interfaces/interfaces';
import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-settings-dialog.component'; import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-settings-dialog.component';
@ -58,9 +59,8 @@ export class GfRuleComponent implements OnInit {
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>(); @Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>();
private deviceType: string; private deviceType: string;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog private dialog: MatDialog
) { ) {
@ -94,7 +94,7 @@ export class GfRuleComponent implements OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((settings: RuleSettings) => { .subscribe((settings: RuleSettings) => {
if (settings) { if (settings) {
this.ruleUpdated.emit({ this.ruleUpdated.emit({
@ -115,9 +115,4 @@ export class GfRuleComponent implements OnInit {
this.ruleUpdated.emit(settings); this.ruleUpdated.emit(settings);
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

5
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 { 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
@ -76,7 +75,6 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
private notificationService: NotificationService, private notificationService: NotificationService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService,
private userService: UserService private userService: UserService
) { ) {
const { globalPermissions } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
@ -161,8 +159,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
.subscribe(({ accessToken }) => { .subscribe(({ accessToken }) => {
this.notificationService.alert({ this.notificationService.alert({
discardFn: () => { discardFn: () => {
this.tokenStorageService.signOut(); this.userService.signOut();
this.userService.remove();
document.location.href = `/${document.documentElement.lang}`; document.location.href = `/${document.documentElement.lang}`;
}, },

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

@ -3,7 +3,6 @@ import {
KEY_TOKEN, KEY_TOKEN,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
@ -108,7 +107,6 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
private notificationService: NotificationService, private notificationService: NotificationService,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private tokenStorageService: TokenStorageService,
private userService: UserService, private userService: UserService,
public webAuthnService: WebAuthnService public webAuthnService: WebAuthnService
) { ) {
@ -198,8 +196,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
) )
.subscribe(() => { .subscribe(() => {
this.tokenStorageService.signOut(); this.userService.signOut();
this.userService.remove();
document.location.href = `/${document.documentElement.lang}`; document.location.href = `/${document.documentElement.lang}`;
}); });

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

@ -68,7 +68,7 @@ export class AuthGuard {
this.dataService this.dataService
.putUserSetting({ language: document.documentElement.lang }) .putUserSetting({ language: document.documentElement.lang })
.subscribe(() => { .subscribe(() => {
this.userService.remove(); this.userService.reset();
setTimeout(() => { setTimeout(() => {
window.location.reload(); 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 { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
@ -32,8 +32,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
public constructor( public constructor(
private dataService: DataService, private dataService: DataService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private userService: UserService,
private webAuthnService: WebAuthnService private webAuthnService: WebAuthnService
) { ) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
@ -115,7 +115,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(internalRoutes.webauthn.routerLink); this.router.navigate(internalRoutes.webauthn.routerLink);
} else { } 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, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
OnDestroy, DestroyRef,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
@ -24,8 +25,6 @@ import {
sparklesOutline sparklesOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page has-tabs' }, host: { class: 'page has-tabs' },
@ -35,17 +34,16 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./about-page.scss'], styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html' templateUrl: './about-page.html'
}) })
export class AboutPageComponent implements OnDestroy, OnInit { export class AboutPageComponent implements OnInit {
public deviceType: string; public deviceType: string;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
@ -57,7 +55,7 @@ export class AboutPageComponent implements OnDestroy, OnInit {
); );
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
this.tabs = [ this.tabs = [
{ {
@ -118,9 +116,4 @@ export class AboutPageComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

10
apps/client/src/app/pages/about/about-page.routes.ts

@ -15,29 +15,29 @@ export const routes: Routes = [
import('./overview/about-overview-page.routes').then((m) => m.routes) import('./overview/about-overview-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes.changelog.path, path: publicRoutes.about.subRoutes?.changelog.path,
loadChildren: () => loadChildren: () =>
import('./changelog/changelog-page.routes').then((m) => m.routes) import('./changelog/changelog-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes.license.path, path: publicRoutes.about.subRoutes?.license.path,
loadChildren: () => loadChildren: () =>
import('./license/license-page.routes').then((m) => m.routes) import('./license/license-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes.ossFriends.path, path: publicRoutes.about.subRoutes?.ossFriends.path,
loadChildren: () => loadChildren: () =>
import('./oss-friends/oss-friends-page.routes').then((m) => m.routes) import('./oss-friends/oss-friends-page.routes').then((m) => m.routes)
}, },
{ {
path: publicRoutes.about.subRoutes.privacyPolicy.path, path: publicRoutes.about.subRoutes?.privacyPolicy.path,
loadChildren: () => loadChildren: () =>
import('./privacy-policy/privacy-policy-page.routes').then( import('./privacy-policy/privacy-policy-page.routes').then(
(m) => m.routes (m) => m.routes
) )
}, },
{ {
path: publicRoutes.about.subRoutes.termsOfService.path, path: publicRoutes.about.subRoutes?.termsOfService.path,
loadChildren: () => loadChildren: () =>
import('./terms-of-service/terms-of-service-page.routes').then( import('./terms-of-service/terms-of-service-page.routes').then(
(m) => m.routes (m) => m.routes

2
apps/client/src/app/pages/about/changelog/changelog-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfChangelogPageComponent, component: GfChangelogPageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes.changelog.title title: publicRoutes.about.subRoutes?.changelog.title
} }
]; ];

2
apps/client/src/app/pages/about/license/license-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfLicensePageComponent, component: GfLicensePageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes.license.title title: publicRoutes.about.subRoutes?.license.title
} }
]; ];

2
apps/client/src/app/pages/about/oss-friends/oss-friends-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfOpenSourceSoftwareFriendsPageComponent, component: GfOpenSourceSoftwareFriendsPageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes.ossFriends.title title: publicRoutes.about.subRoutes?.ossFriends.title
} }
]; ];

2
apps/client/src/app/pages/about/privacy-policy/privacy-policy-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfPrivacyPolicyPageComponent, component: GfPrivacyPolicyPageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes.privacyPolicy.title title: publicRoutes.about.subRoutes?.privacyPolicy.title
} }
]; ];

12
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.component.ts

@ -1,6 +1,5 @@
import { Component, OnDestroy } from '@angular/core'; import { Component } from '@angular/core';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { Subject } from 'rxjs';
@Component({ @Component({
imports: [MarkdownModule], imports: [MarkdownModule],
@ -8,11 +7,4 @@ import { Subject } from 'rxjs';
styleUrls: ['./terms-of-service-page.scss'], styleUrls: ['./terms-of-service-page.scss'],
templateUrl: './terms-of-service-page.html' templateUrl: './terms-of-service-page.html'
}) })
export class GfTermsOfServicePageComponent implements OnDestroy { export class GfTermsOfServicePageComponent {}
private unsubscribeSubject = new Subject<void>();
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

2
apps/client/src/app/pages/about/terms-of-service/terms-of-service-page.routes.ts

@ -10,6 +10,6 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: GfTermsOfServicePageComponent, component: GfTermsOfServicePageComponent,
path: '', path: '',
title: publicRoutes.about.subRoutes.termsOfService.title title: publicRoutes.about.subRoutes?.termsOfService.title
} }
]; ];

54
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -13,7 +13,13 @@ import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import {
ChangeDetectorRef,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -21,8 +27,8 @@ import { Account as AccountModel } from '@prisma/client';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons'; import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subject, Subscription } from 'rxjs'; import { EMPTY, Subscription } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { GfCreateOrUpdateAccountDialogComponent } from './create-or-update-account-dialog/create-or-update-account-dialog.component'; import { GfCreateOrUpdateAccountDialogComponent } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
import { CreateOrUpdateAccountDialogParams } from './create-or-update-account-dialog/interfaces/interfaces'; import { CreateOrUpdateAccountDialogParams } from './create-or-update-account-dialog/interfaces/interfaces';
@ -36,7 +42,7 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
styleUrls: ['./accounts-page.scss'], styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
}) })
export class GfAccountsPageComponent implements OnDestroy, OnInit { export class GfAccountsPageComponent implements OnInit {
public accounts: AccountModel[]; public accounts: AccountModel[];
public activitiesCount = 0; public activitiesCount = 0;
public deviceType: string; public deviceType: string;
@ -48,11 +54,10 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
public totalValueInBaseCurrency = 0; public totalValueInBaseCurrency = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
@ -62,7 +67,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
private userService: UserService private userService: UserService
) { ) {
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
if (params['accountId'] && params['accountDetailDialog']) { if (params['accountId'] && params['accountDetailDialog']) {
this.openAccountDetailDialog(params['accountId']); this.openAccountDetailDialog(params['accountId']);
@ -94,13 +99,13 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -124,7 +129,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
public fetchAccounts() { public fetchAccounts() {
this.dataService this.dataService
.fetchAccounts() .fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe(
({ ({
accounts, accounts,
@ -151,11 +156,11 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.deleteAccount(aId) .deleteAccount(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchAccounts(); this.fetchAccounts();
@ -204,18 +209,18 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((account: UpdateAccountDto | null) => { .subscribe((account: UpdateAccountDto | null) => {
if (account) { if (account) {
this.reset(); this.reset();
this.dataService this.dataService
.putAccount(account) .putAccount(account)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchAccounts(); this.fetchAccounts();
@ -228,11 +233,6 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openAccountDetailDialog(aAccountId: string) { private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent, GfAccountDetailDialogComponent,
@ -245,7 +245,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) && hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView !this.user?.settings?.isRestrictedView
}, },
height: this.deviceType === 'mobile' ? '98vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',
@ -254,7 +254,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchAccounts(); this.fetchAccounts();
@ -284,18 +284,18 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((account: CreateAccountDto | null) => { .subscribe((account: CreateAccountDto | null) => {
if (account) { if (account) {
this.reset(); this.reset();
this.dataService this.dataService
.postAccount(account) .postAccount(account)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
.get(true) .get(true)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
this.fetchAccounts(); this.fetchAccounts();
@ -321,7 +321,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: any) => { .subscribe((data: any) => {
if (data) { if (data) {
this.reset(); this.reset();
@ -343,7 +343,7 @@ export class GfAccountsPageComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe(() => {
this.fetchAccounts(); this.fetchAccounts();

18
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -5,12 +5,7 @@ import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { CommonModule, NgClass } from '@angular/common'; import { CommonModule, NgClass } from '@angular/common';
import { import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { import {
AbstractControl, AbstractControl,
FormBuilder, FormBuilder,
@ -30,7 +25,7 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
import { Observable, Subject } from 'rxjs'; import { Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators'; import { map, startWith } from 'rxjs/operators';
import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
@ -55,14 +50,12 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-account-dialog.scss'], styleUrls: ['./create-or-update-account-dialog.scss'],
templateUrl: 'create-or-update-account-dialog.html' templateUrl: 'create-or-update-account-dialog.html'
}) })
export class GfCreateOrUpdateAccountDialogComponent implements OnDestroy { export class GfCreateOrUpdateAccountDialogComponent {
public accountForm: FormGroup; public accountForm: FormGroup;
public currencies: string[] = []; public currencies: string[] = [];
public filteredPlatforms: Observable<Platform[]>; public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[] = []; public platforms: Platform[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams,
private dataService: DataService, private dataService: DataService,
@ -170,11 +163,6 @@ export class GfCreateOrUpdateAccountDialogComponent implements OnDestroy {
} }
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private autocompleteObjectValidator(): ValidatorFn { private autocompleteObjectValidator(): ValidatorFn {
return (control: AbstractControl) => { return (control: AbstractControl) => {
if (control.value && typeof control.value === 'string') { if (control.value && typeof control.value === 'string') {

17
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -1,12 +1,7 @@
import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { import {
AbstractControl, AbstractControl,
FormBuilder, FormBuilder,
@ -25,7 +20,6 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
import { Subject } from 'rxjs';
import { TransferBalanceDialogParams } from './interfaces/interfaces'; import { TransferBalanceDialogParams } from './interfaces/interfaces';
@ -45,13 +39,11 @@ import { TransferBalanceDialogParams } from './interfaces/interfaces';
styleUrls: ['./transfer-balance-dialog.scss'], styleUrls: ['./transfer-balance-dialog.scss'],
templateUrl: 'transfer-balance-dialog.html' templateUrl: 'transfer-balance-dialog.html'
}) })
export class GfTransferBalanceDialogComponent implements OnDestroy { export class GfTransferBalanceDialogComponent {
public accounts: Account[] = []; public accounts: Account[] = [];
public currency: string; public currency: string;
public transferBalanceForm: FormGroup; public transferBalanceForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: TransferBalanceDialogParams, @Inject(MAT_DIALOG_DATA) public data: TransferBalanceDialogParams,
public dialogRef: MatDialogRef<GfTransferBalanceDialogComponent>, public dialogRef: MatDialogRef<GfTransferBalanceDialogComponent>,
@ -93,11 +85,6 @@ export class GfTransferBalanceDialogComponent implements OnDestroy {
this.dialogRef.close({ account }); this.dialogRef.close({ account });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private compareAccounts(control: AbstractControl): ValidationErrors { private compareAccounts(control: AbstractControl): ValidationErrors {
const accountFrom = control.get('fromAccount'); const accountFrom = control.get('fromAccount');
const accountTo = control.get('toAccount'); const accountTo = control.get('toAccount');

20
apps/client/src/app/pages/admin/admin-page.routes.ts

@ -20,29 +20,29 @@ export const routes: Routes = [
title: internalRoutes.adminControl.title title: internalRoutes.adminControl.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes.jobs.path, path: internalRoutes.adminControl.subRoutes?.jobs.path,
component: GfAdminJobsComponent, component: GfAdminJobsComponent,
title: internalRoutes.adminControl.subRoutes.jobs.title title: internalRoutes.adminControl.subRoutes?.jobs.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes.marketData.path, path: internalRoutes.adminControl.subRoutes?.marketData.path,
component: GfAdminMarketDataComponent, component: GfAdminMarketDataComponent,
title: internalRoutes.adminControl.subRoutes.marketData.title title: internalRoutes.adminControl.subRoutes?.marketData.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes.settings.path, path: internalRoutes.adminControl.subRoutes?.settings.path,
component: GfAdminSettingsComponent, component: GfAdminSettingsComponent,
title: internalRoutes.adminControl.subRoutes.settings.title title: internalRoutes.adminControl.subRoutes?.settings.title
}, },
{ {
path: internalRoutes.adminControl.subRoutes.users.path, path: internalRoutes.adminControl.subRoutes?.users.path,
component: GfAdminUsersComponent, component: GfAdminUsersComponent,
title: internalRoutes.adminControl.subRoutes.users.title title: internalRoutes.adminControl.subRoutes?.users.title
}, },
{ {
path: `${internalRoutes.adminControl.subRoutes.users.path}/:userId`, path: `${internalRoutes.adminControl.subRoutes?.users.path}/:userId`,
component: GfAdminUsersComponent, component: GfAdminUsersComponent,
title: internalRoutes.adminControl.subRoutes.users.title title: internalRoutes.adminControl.subRoutes?.users.title
} }
], ],
component: AdminPageComponent, component: AdminPageComponent,

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

@ -4,20 +4,18 @@ import {
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, DestroyRef, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'gf-auth-page', selector: 'gf-auth-page',
styleUrls: ['./auth-page.scss'], styleUrls: ['./auth-page.scss'],
templateUrl: './auth-page.html' templateUrl: './auth-page.html'
}) })
export class GfAuthPageComponent implements OnDestroy, OnInit { export class GfAuthPageComponent implements OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private destroyRef: DestroyRef,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
@ -26,7 +24,7 @@ export class GfAuthPageComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.route.params this.route.params
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
const jwt = params['jwt']; const jwt = params['jwt'];
@ -38,9 +36,4 @@ export class GfAuthPageComponent implements OnDestroy, OnInit {
this.router.navigate(['/']); this.router.navigate(['/']);
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

2
apps/client/src/app/pages/blog/2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component.ts

@ -12,6 +12,6 @@ import { RouterModule } from '@angular/router';
}) })
export class GhostfolioJoinsOssFriendsPageComponent { export class GhostfolioJoinsOssFriendsPageComponent {
public routerLinkAboutOssFriends = public routerLinkAboutOssFriends =
publicRoutes.about.subRoutes.ossFriends.routerLink; publicRoutes.about.subRoutes?.ossFriends.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink; public routerLinkBlog = publicRoutes.blog.routerLink;
} }

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

Loading…
Cancel
Save