Browse Source

Merge branch 'main' into pr/3393

pull/3393/head
Thomas Kaul 1 year ago
parent
commit
d76f91c2af
  1. 10
      .eslintrc.json
  2. 1
      .gitignore
  3. 1
      .prettierignore
  4. 146
      CHANGELOG.md
  5. 3
      Dockerfile
  6. 6
      README.md
  7. 2
      apps/api/src/app/account/account.controller.ts
  8. 8
      apps/api/src/app/account/account.module.ts
  9. 2
      apps/api/src/app/admin/admin.controller.ts
  10. 4
      apps/api/src/app/admin/admin.module.ts
  11. 33
      apps/api/src/app/admin/admin.service.ts
  12. 2
      apps/api/src/app/app.module.ts
  13. 29
      apps/api/src/app/asset/asset.controller.ts
  14. 17
      apps/api/src/app/asset/asset.module.ts
  15. 2
      apps/api/src/app/auth-device/auth-device.module.ts
  16. 6
      apps/api/src/app/auth-device/auth-device.service.ts
  17. 15
      apps/api/src/app/auth/google.strategy.ts
  18. 8
      apps/api/src/app/benchmark/benchmark.controller.ts
  19. 8
      apps/api/src/app/benchmark/benchmark.module.ts
  20. 4
      apps/api/src/app/benchmark/benchmark.service.ts
  21. 16
      apps/api/src/app/cache/cache.module.ts
  22. 14
      apps/api/src/app/export/export.module.ts
  23. 2
      apps/api/src/app/health/health.controller.ts
  24. 8
      apps/api/src/app/health/health.module.ts
  25. 4
      apps/api/src/app/import/import.controller.ts
  26. 6
      apps/api/src/app/import/import.module.ts
  27. 8
      apps/api/src/app/import/import.service.ts
  28. 2
      apps/api/src/app/info/info.controller.ts
  29. 2
      apps/api/src/app/info/info.module.ts
  30. 2
      apps/api/src/app/logo/logo.controller.ts
  31. 7
      apps/api/src/app/logo/logo.module.ts
  32. 6
      apps/api/src/app/order/create-order.dto.ts
  33. 22
      apps/api/src/app/order/order.controller.ts
  34. 10
      apps/api/src/app/order/order.module.ts
  35. 30
      apps/api/src/app/order/order.service.ts
  36. 6
      apps/api/src/app/order/update-order.dto.ts
  37. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  38. 2
      apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts
  39. 5
      apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts
  40. 105
      apps/api/src/app/portfolio/portfolio.controller.ts
  41. 6
      apps/api/src/app/portfolio/portfolio.module.ts
  42. 8
      apps/api/src/app/portfolio/portfolio.service.ts
  43. 50
      apps/api/src/app/sitemap/sitemap.controller.ts
  44. 16
      apps/api/src/app/sitemap/sitemap.module.ts
  45. 2
      apps/api/src/app/subscription/subscription.service.ts
  46. 4
      apps/api/src/app/symbol/symbol.controller.ts
  47. 8
      apps/api/src/app/symbol/symbol.module.ts
  48. 6
      apps/api/src/app/user/delete-own-user.dto.ts
  49. 31
      apps/api/src/app/user/user.controller.ts
  50. 31
      apps/api/src/app/user/user.service.ts
  51. 2448
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  52. 941
      apps/api/src/assets/sitemap.xml
  53. 19
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  54. 4
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts
  55. 0
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  56. 11
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts
  57. 0
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  58. 11
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts
  59. 4
      apps/api/src/main.ts
  60. 3
      apps/api/src/services/data-gathering/data-gathering.service.ts
  61. 4
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  62. 1
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts
  63. 20
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  64. 12
      apps/api/src/services/data-provider/data-provider.service.ts
  65. 4
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  66. 4
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  67. 4
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  68. 4
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  69. 2
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  70. 35
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  71. 18
      apps/client/localhost.cert
  72. 28
      apps/client/localhost.pem
  73. 5
      apps/client/project.json
  74. 50
      apps/client/src/app/app-routing.module.ts
  75. 57
      apps/client/src/app/app.component.html
  76. 87
      apps/client/src/app/app.component.ts
  77. 7
      apps/client/src/app/app.module.ts
  78. 6
      apps/client/src/app/components/access-table/access-table.component.html
  79. 15
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  80. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  81. 46
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  82. 2
      apps/client/src/app/components/accounts-table/accounts-table.component.scss
  83. 55
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  84. 7
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
  85. 1
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  86. 19
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  87. 15
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  88. 31
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  89. 7
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html
  90. 37
      apps/client/src/app/components/admin-overview/admin-overview.html
  91. 3
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  92. 7
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html
  93. 7
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html
  94. 37
      apps/client/src/app/components/admin-users/admin-users.html
  95. 6
      apps/client/src/app/components/admin-users/admin-users.scss
  96. 27
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  97. 8
      apps/client/src/app/components/dialog-footer/dialog-footer.component.html
  98. 9
      apps/client/src/app/components/dialog-header/dialog-header.component.html
  99. 3
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html
  100. 109
      apps/client/src/app/components/header/header.component.html

10
.eslintrc.json

@ -24,12 +24,18 @@
{ {
"files": ["*.ts", "*.tsx"], "files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"], "extends": ["plugin:@nx/typescript"],
"rules": {} "rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
}, },
{ {
"files": ["*.js", "*.jsx"], "files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"], "extends": ["plugin:@nx/javascript"],
"rules": {} "rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
}, },
{ {
"files": ["*.ts"], "files": ["*.ts"],

1
.gitignore

@ -28,6 +28,7 @@
.env .env
.env.prod .env.prod
.nx/cache .nx/cache
.nx/workspace-data
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage

1
.prettierignore

@ -1,4 +1,5 @@
/.nx/cache /.nx/cache
/.nx/workspace-data
/apps/client/src/polyfills.ts /apps/client/src/polyfills.ts
/dist /dist
/test/import /test/import

146
CHANGELOG.md

@ -7,8 +7,154 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added a dialog for the benchmarks in the markets overview
- Extended the content of the _Self-Hosting_ section by the mobile app question on the Frequently Asked Questions (FAQ) page
### Changed
- Moved the indicator for active filters from experimental to general availability
- Improved the error handling in the biometric authentication registration
- Set up SSL for local development
- Upgraded the _Stripe_ dependencies
- Upgraded `marked` from version `9.1.6` to `13.0.0`
- Upgraded `ngx-markdown` from version `17.1.1` to `18.0.0`
- Upgraded `zone.js` from version `0.14.5` to `0.14.7`
## 2.89.0 - 2024-06-14
### Added
- Extended the historical market data table with currencies preset by date and activities count in the admin control panel
### Changed
- Improved the date validation in the create, import and update activities endpoints
- Improved the language localization for German (`de`)
## 2.88.0 - 2024-06-11
### Added
- Set the image source label in `Dockerfile`
### Changed
- Improved the style of the blog post list
- Migrated the `@ghostfolio/client` components to control flow
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `17.3.10` to `18.0.2`
- Upgraded `Nx` from version `19.0.5` to `19.2.2`
## 2.87.0 - 2024-06-08
### Changed
- Improved the portfolio summary
- Improved the allocations by ETF holding on the allocations page (experimental)
- Improved the error handling in the `HttpResponseInterceptor`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.14.0` to `5.15.0`
### Fixed
- Fixed an issue in the _FIRE_ calculator
## 2.86.0 - 2024-06-07
### Added
- Introduced the allocations by ETF holding on the allocations page (experimental)
### Changed
- Upgraded `prettier` from version `3.2.5` to `3.3.1`
## 2.85.0 - 2024-06-06
### Added
- Added the ability to close a user account
### Changed
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.10.0` to `2.12.0`
### Fixed
- Fixed an issue with the default locale in the value component
## 2.84.0 - 2024-06-01
### Added
- Added the data provider information to the asset profile details dialog of the admin control
- Added the cascading on delete for various relations in the database schema
### Fixed
- Fixed an issue with the initial annual interest rate in the _FIRE_ calculator
- Fixed the state handling in the currency selector
- Fixed the deletion of an asset profile with symbol profile overrides in the asset profile details dialog of the admin control
## 2.83.0 - 2024-05-30
### Changed
- Upgraded `@nestjs/passport` from version `10.0.0` to `10.0.3`
- Upgraded `angular` from version `17.3.5` to `17.3.10`
- Upgraded `class-validator` from version `0.14.0` to `0.14.1`
- Upgraded `countup.js` from version `2.3.2` to `2.8.0`
- Upgraded `Nx` from version `19.0.2` to `19.0.5`
- Upgraded `passport` from version `0.6.0` to `0.7.0`
- Upgraded `passport-jwt` from version `4.0.0` to `4.0.1`
- Upgraded `prisma` from version `5.13.0` to `5.14.0`
- Upgraded `yahoo-finance2` from version `2.11.2` to `2.11.3`
## 2.82.0 - 2024-05-22
### Changed
- Improved the usability of the create or update activity dialog by preselecting the (only) account
- Improved the usability of the date range selector in the assistant
- Refactored the holding detail dialog to a standalone component
- Refreshed the cryptocurrencies list
- Refactored various pages to standalone components
- Upgraded `@internationalized/number` from version `3.5.0` to `3.5.2`
- Upgraded `body-parser` from version `1.20.1` to `1.20.2`
- Upgraded `zone.js` from version `0.14.4` to `0.14.5`
## 2.81.0 - 2024-05-12
### Added
- Added an indicator for active filters (experimental)
### Changed
- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant
- Improved the language localization for German (`de`)
- Improved the language localization for Türkçe (`tr`)
- Upgraded `Nx` from version `18.3.3` to `19.0.2`
### Fixed
- Fixed the position detail dialog close functionality
## 2.80.0 - 2024-05-08
### Added
- Added the absolute change column to the holdings table on the home page
### Changed ### Changed
- Increased the spacing around the floating action buttons (FAB)
- Set the icon column of the activities table to stick at the beginning
- Set the icon column of the holdings table to stick at the beginning
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
- Upgraded `ionicons` from version `7.3.0` to `7.4.0` - Upgraded `ionicons` from version `7.3.0` to `7.4.0`
### Fixed ### Fixed

3
Dockerfile

@ -51,6 +51,9 @@ RUN yarn database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:18-slim FROM node:18-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
RUN apt update && apt install -y \ RUN apt update && apt install -y \
curl \ curl \
openssl \ openssl \

6
README.md

@ -161,7 +161,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
1. Run `yarn database:setup` to initialize the database schema 1. Run `yarn database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks 1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Open http://localhost:4200/en in your browser 1. Open https://localhost:4200/en in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
### Start Server ### Start Server
@ -176,7 +176,7 @@ Run `yarn start:server`
### Start Client ### Start Client
Run `yarn start:client` and open http://localhost:4200/en in your browser Run `yarn start:client` and open https://localhost:4200/en in your browser
### Start _Storybook_ ### Start _Storybook_
@ -275,7 +275,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

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

@ -2,7 +2,7 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {

8
apps/api/src/app/account/account.module.ts

@ -1,9 +1,7 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.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 { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -19,13 +17,11 @@ import { AccountService } from './account.service';
imports: [ imports: [
AccountBalanceModule, AccountBalanceModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedactValuesInResponseModule
UserModule
], ],
providers: [AccountService] providers: [AccountService]
}) })

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

@ -1,6 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';

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

@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -27,7 +28,8 @@ import { QueueModule } from './queue/queue.module';
PropertyModule, PropertyModule,
QueueModule, QueueModule,
SubscriptionModule, SubscriptionModule,
SymbolProfileModule SymbolProfileModule,
TransformDataSourceInRequestModule
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService], providers: [AdminService],

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

@ -313,6 +313,12 @@ export class AdminService {
}) })
]); ]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return { return {
marketData, marketData,
assetProfile: assetProfile ?? { assetProfile: assetProfile ?? {
@ -329,6 +335,7 @@ export class AdminService {
countries, countries,
currency, currency,
dataSource, dataSource,
holdings,
name, name,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
@ -349,6 +356,7 @@ export class AdminService {
countries, countries,
currency, currency,
dataSource, dataSource,
holdings,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol,
@ -401,9 +409,24 @@ export class AdminService {
by: ['dataSource', 'symbol'] by: ['dataSource', 'symbol']
}); });
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService const marketDataPromise: Promise<AdminMarketDataItem>[] =
this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.map(({ dataSource, symbol }) => { .map(async ({ dataSource, symbol }) => {
const currency = symbol.replace(DEFAULT_CURRENCY, '');
const { _count, _min } = await this.prismaService.order.aggregate({
_count: true,
_min: {
date: true
},
where: {
SymbolProfile: {
currency
}
}
});
const marketDataItemCount = const marketDataItemCount =
marketDataItems.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
@ -413,18 +436,22 @@ export class AdminService {
})?._count ?? 0; })?._count ?? 0;
return { return {
currency,
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
activitiesCount: _count as number,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''), date: _min.date,
id: undefined, id: undefined,
name: symbol, name: symbol,
sectorsCount: 0 sectorsCount: 0
}; };
}); });
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length }; return { marketData, count: marketData.length };
} }

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

@ -25,6 +25,7 @@ import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.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 { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
@ -51,6 +52,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AssetModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule, BenchmarkModule,

29
apps/api/src/app/asset/asset.controller.ts

@ -0,0 +1,29 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { pick } from 'lodash';
@Controller('asset')
export class AssetController {
public constructor(private readonly adminService: AdminService) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAsset(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
return {
marketData,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
};
}
}

17
apps/api/src/app/asset/asset.module.ts

@ -0,0 +1,17 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { Module } from '@nestjs/common';
import { AssetController } from './asset.controller';
@Module({
controllers: [AssetController],
imports: [
AdminModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
})
export class AssetModule {}

2
apps/api/src/app/auth-device/auth-device.module.ts

@ -1,6 +1,5 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -9,7 +8,6 @@ import { JwtModule } from '@nestjs/jwt';
@Module({ @Module({
controllers: [AuthDeviceController], controllers: [AuthDeviceController],
imports: [ imports: [
ConfigurationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }

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

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -6,10 +5,7 @@ import { AuthDevice, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class AuthDeviceService { export class AuthDeviceService {
public constructor( public constructor(private readonly prismaService: PrismaService) {}
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
) {}
public async authDevice( public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput where: Prisma.AuthDeviceWhereUniqueInput

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

@ -3,7 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20'; import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
super({ super({
callbackURL: `${configurationService.get( callbackURL: `${configurationService.get(
@ -20,7 +20,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
clientID: configurationService.get('GOOGLE_CLIENT_ID'), clientID: configurationService.get('GOOGLE_CLIENT_ID'),
clientSecret: configurationService.get('GOOGLE_SECRET'), clientSecret: configurationService.get('GOOGLE_SECRET'),
passReqToCallback: true, passReqToCallback: true,
scope: ['email', 'profile'] scope: ['profile']
}); });
} }
@ -28,20 +28,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
request: any, request: any,
token: string, token: string,
refreshToken: string, refreshToken: string,
profile, profile: Profile,
done: Function, done: Function,
done2: Function done2: Function
) { ) {
try { try {
const jwt: string = await this.authService.validateOAuthLogin({ const jwt = await this.authService.validateOAuthLogin({
provider: Provider.GOOGLE, provider: Provider.GOOGLE,
thirdPartyId: profile.id thirdPartyId: profile.id
}); });
const user = {
jwt
};
done(null, user); done(null, { jwt });
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleStrategy'); Logger.error(error, 'GoogleStrategy');
done(error, false); done(error, false);

8
apps/api/src/app/benchmark/benchmark.controller.ts

@ -1,8 +1,8 @@
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 { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { import type {
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
@ -105,7 +105,7 @@ export class BenchmarkController {
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataForUser(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@ -117,7 +117,7 @@ export class BenchmarkController {
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({ return this.benchmarkService.getMarketDataForUser({
dataSource, dataSource,
endDate, endDate,
startDate, startDate,

8
apps/api/src/app/benchmark/benchmark.module.ts

@ -1,6 +1,7 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -17,7 +18,6 @@ import { BenchmarkService } from './benchmark.service';
controllers: [BenchmarkController], controllers: [BenchmarkController],
exports: [BenchmarkService], exports: [BenchmarkService],
imports: [ imports: [
ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
@ -25,7 +25,9 @@ import { BenchmarkService } from './benchmark.service';
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule, SymbolModule,
SymbolProfileModule SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
], ],
providers: [BenchmarkService] providers: [BenchmarkService]
}) })

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

@ -153,6 +153,7 @@ export class BenchmarkService {
} }
return { return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
), ),
@ -163,6 +164,7 @@ export class BenchmarkService {
performancePercent: performancePercentFromAllTimeHigh performancePercent: performancePercentFromAllTimeHigh
} }
}, },
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d, trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d trend200d: benchmarkTrends[index].trend200d
}; };
@ -213,7 +215,7 @@ export class BenchmarkService {
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
public async getMarketDataBySymbol({ public async getMarketDataForUser({
dataSource, dataSource,
endDate = new Date(), endDate = new Date(),
startDate, startDate,

16
apps/api/src/app/cache/cache.module.ts

@ -1,10 +1,4 @@
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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,14 +6,6 @@ import { CacheController } from './cache.controller';
@Module({ @Module({
controllers: [CacheController], controllers: [CacheController],
imports: [ imports: [RedisCacheModule]
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
}) })
export class CacheModule {} export class CacheModule {}

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

@ -1,10 +1,6 @@
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 { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,15 +8,7 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [ imports: [AccountModule, ApiModule, OrderModule],
AccountModule,
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
OrderModule,
RedisCacheModule
],
controllers: [ExportController], controllers: [ExportController],
providers: [ExportService] providers: [ExportService]
}) })

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

@ -1,4 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { import {
Controller, Controller,

8
apps/api/src/app/health/health.module.ts

@ -1,4 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -9,7 +9,11 @@ import { HealthService } from './health.service';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule], imports: [
DataEnhancerModule,
DataProviderModule,
TransformDataSourceInRequestModule
],
providers: [HealthService] providers: [HealthService]
}) })
export class HealthModule {} export class HealthModule {}

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

@ -1,7 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';

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

@ -4,6 +4,8 @@ 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';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -30,7 +32,9 @@ import { ImportService } from './import.service';
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
], ],
providers: [ImportService] providers: [ImportService]
}) })

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

@ -13,10 +13,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
@ -295,6 +292,7 @@ export class ImportService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
id, id,
isin, isin,
name, name,
@ -367,6 +365,7 @@ export class ImportService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
id, id,
isin, isin,
name, name,
@ -538,6 +537,7 @@ export class ImportService {
assetSubClass: undefined, assetSubClass: undefined,
countries: undefined, countries: undefined,
createdAt: undefined, createdAt: undefined,
holdings: undefined,
id: undefined, id: undefined,
sectors: undefined, sectors: undefined,
updatedAt: undefined updatedAt: undefined

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

@ -1,4 +1,4 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common'; import { Controller, Get, UseInterceptors } from '@nestjs/common';

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

@ -2,6 +2,7 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.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 { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -34,6 +35,7 @@ import { InfoService } from './info.service';
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule, TagModule,
TransformDataSourceInResponseModule,
UserModule UserModule
], ],
providers: [InfoService] providers: [InfoService]

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

@ -1,4 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { import {
Controller, Controller,

7
apps/api/src/app/logo/logo.module.ts

@ -1,3 +1,4 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -8,7 +9,11 @@ import { LogoService } from './logo.service';
@Module({ @Module({
controllers: [LogoController], controllers: [LogoController],
imports: [ConfigurationModule, SymbolProfileModule], imports: [
ConfigurationModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],
providers: [LogoService] providers: [LogoService]
}) })
export class LogoModule {} export class LogoModule {}

6
apps/api/src/app/order/create-order.dto.ts

@ -1,3 +1,5 @@
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -15,7 +17,8 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min Min,
Validate
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -51,6 +54,7 @@ export class CreateOrderDto {
dataSource?: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint)
date: string; date: string;
@IsNumber() @IsNumber()

22
apps/api/src/app/order/order.controller.ts

@ -1,9 +1,9 @@
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 { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
@ -11,7 +11,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -53,8 +53,20 @@ export class OrderController {
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> { public async deleteOrders(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.orderService.deleteOrders({ return this.orderService.deleteOrders({
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

10
apps/api/src/app/order/order.module.ts

@ -2,9 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.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 { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -23,15 +24,16 @@ import { OrderService } from './order.service';
imports: [ imports: [
ApiModule, ApiModule,
CacheModule, CacheModule,
ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
UserModule TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
], ],
providers: [AccountBalanceService, AccountService, OrderService] providers: [AccountBalanceService, AccountService, OrderService]
}) })

30
apps/api/src/app/order/order.service.ts

@ -194,16 +194,36 @@ export class OrderService {
return order; return order;
} }
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> { public async deleteOrders({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
filters,
userId,
userCurrency,
includeDrafts: true,
withExcludedAccounts: true
});
const { count } = await this.prismaService.order.deleteMany({ const { count } = await this.prismaService.order.deleteMany({
where where: {
id: {
in: activities.map(({ id }) => {
return id;
})
}
}
}); });
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({ userId })
userId: <string>where.userId
})
); );
return count; return count;

6
apps/api/src/app/order/update-order.dto.ts

@ -1,3 +1,5 @@
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -14,7 +16,8 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min Min,
Validate
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -49,6 +52,7 @@ export class UpdateOrderDto {
dataSource: DataSource; dataSource: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint)
date: string; date: string;
@IsNumber() @IsNumber()

1
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -20,6 +20,7 @@ export const symbolProfileDummyData = {
assetSubClass: undefined, assetSubClass: undefined,
countries: [], countries: [],
createdAt: undefined, createdAt: undefined,
holdings: [],
id: undefined, id: undefined,
sectors: [], sectors: [],
updatedAt: undefined updatedAt: undefined

2
apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts → apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts

@ -7,7 +7,7 @@ import {
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioHoldingDetail {
accounts: Account[]; accounts: Account[];
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;

5
apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts

@ -1,5 +0,0 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

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

@ -7,9 +7,9 @@ import {
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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';
@ -27,6 +27,10 @@ import {
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport PortfolioReport
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import type { import type {
DateRange, DateRange,
GroupBy, GroupBy,
@ -51,8 +55,7 @@ import { AssetClass, AssetSubClass } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
@ -87,11 +90,6 @@ export class PortfolioController {
let hasDetails = true; let hasDetails = true;
let hasError = false; let hasError = false;
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium'; hasDetails = this.request.user.subscription.type === 'Premium';
@ -126,8 +124,11 @@ export class PortfolioController {
let portfolioSummary = summary; let portfolioSummary = summary;
if ( if (
hasReadRestrictedAccessPermission || hasReadRestrictedAccessPermission({
this.userService.isRestrictedView(this.request.user) impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) { ) {
const totalInvestment = Object.values(holdings) const totalInvestment = Object.values(holdings)
.map(({ investment }) => { .map(({ investment }) => {
@ -167,8 +168,11 @@ export class PortfolioController {
if ( if (
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission || hasReadRestrictedAccessPermission({
this.userService.isRestrictedView(this.request.user) impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) { ) {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
@ -208,6 +212,7 @@ export class PortfolioController {
: undefined, : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced ? portfolioPosition.marketsAdvanced
@ -239,12 +244,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDividends> { ): Promise<PortfolioDividends> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -272,8 +271,11 @@ export class PortfolioController {
}); });
if ( if (
hasReadRestrictedAccessPermission || hasReadRestrictedAccessPermission({
this.userService.isRestrictedView(this.request.user) impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) { ) {
const maxDividend = dividends.reduce( const maxDividend = dividends.reduce(
(investment, item) => Math.max(investment, item.investment), (investment, item) => Math.max(investment, item.investment),
@ -339,12 +341,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -360,8 +356,11 @@ export class PortfolioController {
}); });
if ( if (
hasReadRestrictedAccessPermission || hasReadRestrictedAccessPermission({
this.userService.isRestrictedView(this.request.user) impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) { ) {
const maxInvestment = investments.reduce( const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment), (investment, item) => Math.max(investment, item.investment),
@ -410,12 +409,6 @@ export class PortfolioController {
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true'; const withExcludedAccounts = withExcludedAccountsParam === 'true';
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -431,9 +424,12 @@ export class PortfolioController {
}); });
if ( if (
hasReadRestrictedAccessPermission || hasReadRestrictedAccessPermission({
this.request.user.Settings.settings.viewMode === 'ZEN' || impersonationId,
this.userService.isRestrictedView(this.request.user) user: this.request.user
}) ||
isRestrictedView(this.request.user) ||
this.request.user.Settings.settings.viewMode === 'ZEN'
) { ) {
performanceInformation.chart = performanceInformation.chart.map( performanceInformation.chart = performanceInformation.chart.map(
({ ({
@ -506,35 +502,6 @@ export class PortfolioController {
return performanceInformation; return performanceInformation;
} }
/**
* @deprecated
*/
@Get('positions')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
}
@Get('public/:accessId') @Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic( public async getPublic(
@ -611,7 +578,7 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource, @Param('dataSource') dataSource,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition( const position = await this.portfolioService.getPosition(
dataSource, dataSource,
impersonationId, impersonationId,

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

@ -4,6 +4,9 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.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 { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -36,8 +39,11 @@ import { RulesService } from './rules.service';
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule UserModule
], ],
providers: [ providers: [

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

@ -77,7 +77,7 @@ import {
PerformanceCalculationType, PerformanceCalculationType,
PortfolioCalculatorFactory PortfolioCalculatorFactory
} from './calculator/portfolio-calculator.factory'; } from './calculator/portfolio-calculator.factory';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
@ -514,6 +514,7 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings,
investment: investment.toNumber(), investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed', marketState: dataProviderResponse?.marketState ?? 'delayed',
name: assetProfile.name, name: assetProfile.name,
@ -623,7 +624,7 @@ export class PortfolioService {
aDataSource: DataSource, aDataSource: DataSource,
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioHoldingDetail> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
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);
@ -714,7 +715,7 @@ export class PortfolioService {
transactionCount transactionCount
} = position; } = position;
const accounts: PortfolioPositionDetail['accounts'] = uniqBy( const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
orders.filter(({ Account }) => { orders.filter(({ Account }) => {
return Account; return Account;
}), }),
@ -1510,6 +1511,7 @@ export class PortfolioService {
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0, grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance, investment: balance,
marketPrice: 0, marketPrice: 0,
marketState: 'open', marketState: 'open',

50
apps/api/src/app/sitemap/sitemap.controller.ts

@ -1,8 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getYesterday, getYesterday,
interpolate interpolate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -14,7 +16,9 @@ import * as path from 'path';
export class SitemapController { export class SitemapController {
public sitemapXml = ''; public sitemapXml = '';
public constructor() { public constructor(
private readonly configurationService: ConfigurationService
) {
try { try {
this.sitemapXml = fs.readFileSync( this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'), path.join(__dirname, 'assets', 'sitemap.xml'),
@ -25,11 +29,51 @@ export class SitemapController {
@Get() @Get()
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public async flushCache(@Res() response: Response): Promise<void> { public async getSitemapXml(@Res() response: Response): Promise<void> {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml'); response.setHeader('content-type', 'application/xml');
response.send( response.send(
interpolate(this.sitemapXml, { interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT) currentDate,
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? personalFinanceTools
.map(({ alias, key }) => {
return [
'<url>',
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>'
].join('\n');
})
.join('\n')
: ''
}) })
); );
} }

16
apps/api/src/app/sitemap/sitemap.module.ts

@ -1,10 +1,4 @@
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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,14 +6,6 @@ import { SitemapController } from './sitemap.controller';
@Module({ @Module({
controllers: [SitemapController], controllers: [SitemapController],
imports: [ imports: [ConfigurationModule]
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
}) })
export class SitemapModule {} export class SitemapModule {}

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

@ -22,7 +22,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2022-11-15' apiVersion: '2024-04-10'
} }
); );
} }

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

@ -1,6 +1,6 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';

8
apps/api/src/app/symbol/symbol.module.ts

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -12,10 +13,11 @@ import { SymbolService } from './symbol.service';
controllers: [SymbolController], controllers: [SymbolController],
exports: [SymbolService], exports: [SymbolService],
imports: [ imports: [
ConfigurationModule,
DataProviderModule, DataProviderModule,
MarketDataModule, MarketDataModule,
PrismaModule PrismaModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
], ],
providers: [SymbolService] providers: [SymbolService]
}) })

6
apps/api/src/app/user/delete-own-user.dto.ts

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class DeleteOwnUserDto {
@IsString()
accessToken: string;
}

31
apps/api/src/app/user/user.controller.ts

@ -1,5 +1,6 @@
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -25,6 +26,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash'; import { size } from 'lodash';
import { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto'; import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -32,12 +34,41 @@ import { UserService } from './user.service';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Delete()
@HasPermission(permissions.deleteOwnUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOwnUser(
@Body() data: DeleteOwnUserDto
): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken(
data.accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: this.request.user.id }
});
if (!user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({
accessToken: hashedAccessToken,
id: user.id
});
}
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteUser) @HasPermission(permissions.deleteUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -121,28 +121,6 @@ export class UserService {
return usersWithAdminRole.length > 0; return usersWithAdminRole.length > 0;
} }
public hasReadRestrictedAccessPermission({
impersonationId,
user
}: {
impersonationId: string;
user: UserWithSettings;
}) {
if (!impersonationId) {
return false;
}
const access = user.Access?.find(({ id }) => {
return id === impersonationId;
});
return access?.permissions?.includes('READ_RESTRICTED') ?? true;
}
public isRestrictedView(aUser: UserWithSettings) {
return aUser.Settings.settings.isRestrictedView ?? false;
}
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
@ -262,10 +240,13 @@ export class UserService {
// Reset benchmark // Reset benchmark
user.Settings.settings.benchmark = undefined; user.Settings.settings.benchmark = undefined;
} } else if (user.subscription?.type === 'Premium') {
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
currentPermissions = without(
currentPermissions,
permissions.deleteOwnUser
);
} }
} }

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

File diff suppressed because it is too large

941
apps/api/src/assets/sitemap.xml

File diff suppressed because it is too large

19
apps/api/src/interceptors/redact-values-in-response.interceptor.ts → apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -1,6 +1,9 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { import {
@ -16,7 +19,7 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T> export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any> implements NestInterceptor<T, any>
{ {
public constructor(private userService: UserService) {} public constructor() {}
public intercept( public intercept(
context: ExecutionContext, context: ExecutionContext,
@ -29,15 +32,13 @@ export class RedactValuesInResponseInterceptor<T>
const impersonationId = const impersonationId =
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
const hasReadRestrictedPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user
});
if ( if (
hasReadRestrictedPermission || hasReadRestrictedAccessPermission({
this.userService.isRestrictedView(user) impersonationId,
user
}) ||
isRestrictedView(user)
) { ) {
data = redactAttributes({ data = redactAttributes({
object: data, object: data,

4
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts

@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class RedactValuesInResponseModule {}

0
apps/api/src/interceptors/transform-data-source-in-request.interceptor.ts → apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

11
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts

@ -0,0 +1,11 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
@Module({
exports: [ConfigurationService],
imports: [ConfigurationModule],
providers: [ConfigurationService]
})
export class TransformDataSourceInRequestModule {}

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

11
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts

@ -0,0 +1,11 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
@Module({
exports: [ConfigurationService],
imports: [ConfigurationModule],
providers: [ConfigurationService]
})
export class TransformDataSourceInResponseModule {}

4
apps/api/src/main.ts

@ -2,7 +2,7 @@ import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
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 * as bodyParser from 'body-parser'; import { json } from 'body-parser';
import helmet from 'helmet'; import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
@ -34,7 +34,7 @@ async function bootstrap() {
); );
// Support 10mb csv/json files for importing activities // Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' })); app.use(json({ limit: '10mb' }));
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use( app.use(

3
apps/api/src/services/data-gathering/data-gathering.service.ts

@ -181,6 +181,7 @@ export class DataGatheringService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
isin, isin,
name, name,
sectors, sectors,
@ -198,6 +199,7 @@ export class DataGatheringService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
isin, isin,
name, name,
sectors, sectors,
@ -212,6 +214,7 @@ export class DataGatheringService {
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
isin, isin,
name, name,
sectors, sectors,

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

@ -50,7 +50,9 @@ export class AlphaVantageService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
isPremium: false isPremium: false,
name: 'Alpha Vantage',
url: 'https://www.alphavantage.co'
}; };
} }

1
apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts

@ -36,6 +36,7 @@ export class DataEnhancerService {
if ( if (
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 && (assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.holdings as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0 (assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
) { ) {
return true; return true;

20
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { Holding } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -155,11 +156,30 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
} }
} }
if (
!response.holdings ||
(response.holdings as unknown as Holding[]).length === 0
) {
response.holdings = [];
for (const { label, weight } of holdings?.topHoldings ?? []) {
if (label?.toLowerCase() === 'other') {
continue;
}
response.holdings.push({
weight,
name: label
});
}
}
if ( if (
!response.sectors || !response.sectors ||
(response.sectors as unknown as Sector[]).length === 0 (response.sectors as unknown as Sector[]).length === 0
) { ) {
response.sectors = []; response.sectors = [];
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {} holdings?.sectors ?? {}
)) { )) {

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

@ -598,10 +598,14 @@ export class DataProviderService {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
}) })
.map((lookupItem) => { .map((lookupItem) => {
if ( if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') || if (user.subscription.type === 'Premium') {
user.subscription.type === 'Premium' lookupItem.dataProviderInfo.isPremium = false;
) { }
lookupItem.dataProviderInfo.name = undefined;
lookupItem.dataProviderInfo.url = undefined;
} else {
lookupItem.dataProviderInfo.isPremium = false; lookupItem.dataProviderInfo.isPremium = false;
} }

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

@ -66,7 +66,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
isPremium: true isPremium: true,
name: 'EOD Historical Data',
url: 'https://eodhd.com'
}; };
} }

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

@ -46,7 +46,9 @@ export class GoogleSheetsService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
isPremium: false isPremium: false,
name: 'Google Sheets',
url: 'https://docs.google.com/spreadsheets'
}; };
} }

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

@ -43,7 +43,9 @@ export class RapidApiService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
isPremium: false isPremium: false,
name: 'Rapid API',
url: 'https://rapidapi.com'
}; };
} }

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

@ -43,7 +43,9 @@ export class YahooFinanceService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
isPremium: false isPremium: false,
name: 'Yahoo Finance',
url: 'https://finance.yahoo.com'
}; };
} }

2
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -78,7 +78,7 @@ export class ExchangeRateDataService {
); );
const lastDateString = dateStrings.reduce((a, b) => { const lastDateString = dateStrings.reduce((a, b) => {
return a > b ? a : b; return a > b ? a : b;
}); }, undefined);
let previousExchangeRate = let previousExchangeRate =
exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[ exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[

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

@ -2,6 +2,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Holding,
ScraperConfiguration, ScraperConfiguration,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -61,7 +62,9 @@ export class SymbolProfileService {
}) })
} }
}) })
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => {
return this.enhanceSymbolProfiles(symbolProfiles);
});
} }
public async getSymbolProfilesByIds( public async getSymbolProfilesByIds(
@ -83,7 +86,9 @@ export class SymbolProfileService {
} }
} }
}) })
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => {
return this.enhanceSymbolProfiles(symbolProfiles);
});
} }
public updateSymbolProfile({ public updateSymbolProfile({
@ -93,6 +98,7 @@ export class SymbolProfileService {
countries, countries,
currency, currency,
dataSource, dataSource,
holdings,
name, name,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
@ -108,6 +114,7 @@ export class SymbolProfileService {
comment, comment,
countries, countries,
currency, currency,
holdings,
name, name,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
@ -119,7 +126,7 @@ export class SymbolProfileService {
}); });
} }
private getSymbols( private enhanceSymbolProfiles(
symbolProfiles: (SymbolProfile & { symbolProfiles: (SymbolProfile & {
_count: { Order: number }; _count: { Order: number };
Order?: { Order?: {
@ -136,6 +143,7 @@ export class SymbolProfileService {
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfile?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: <Date>undefined, dateOfFirstActivity: <Date>undefined,
holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile) symbolMapping: this.getSymbolMapping(symbolProfile)
@ -163,6 +171,14 @@ export class SymbolProfileService {
); );
} }
if (
(item.SymbolProfileOverrides.holdings as unknown as Holding[])
?.length > 0
) {
item.holdings = item.SymbolProfileOverrides
.holdings as unknown as Holding[];
}
item.name = item.SymbolProfileOverrides?.name ?? item.name; item.name = item.SymbolProfileOverrides?.name ?? item.name;
if ( if (
@ -199,6 +215,19 @@ export class SymbolProfileService {
}); });
} }
private getHoldings(symbolProfile: SymbolProfile): Holding[] {
return ((symbolProfile?.holdings as Prisma.JsonArray) ?? []).map(
(holding) => {
const { name, weight } = holding as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
valueInBaseCurrency: weight as number
};
}
);
}
private getScraperConfiguration( private getScraperConfiguration(
symbolProfile: SymbolProfile symbolProfile: SymbolProfile
): ScraperConfiguration { ): ScraperConfiguration {

18
apps/client/localhost.cert

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC5TCCAc2gAwIBAgIJAJAMHOFnJ6oyMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMMCWxvY2FsaG9zdDAeFw0yNDAyMjcxNTQ2MzFaFw0yNDAzMjgxNTQ2MzFaMBQx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAJ0hRjViikEKVIyukXR4CfuYVvFEFzB6AwAQ9Jrz2MseqpLacLTXFFAS54mp
iDuqPBzs9ta40mlSrqSBuAWKikpW5kTNnmqUnDZ6iSJezbYWx9YyULGqqb1q3C4/
5pH9m6NHJ+2uaUNKlDxYNKbntqs3drQEdxH9yv672Z53nvogTcf9jz6zjutEQGSV
HgVkCTTQmzf3+3st+VJ5D8JeYFR+tpZ6yleqgXFaTMtPZRfKLvTkQ+KeyCJLnsUJ
BQvdCKI0PGsG6d6ygXFmSePolD9KW3VTKKDPCsndID89vAnRWDj9UhzvycxmKpcF
GrUPH5+Pis1PM1R7OnAvnFygnyECAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo
b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B
AQsFAAOCAQEAOdzrY3RTAPnBVAd3/GKIEkielYyOgKPnvd+RcOB+je8cXZY+vaxX
uEFP0526G00+kRZ2Tx9t0SmjoSpwg3lHUPMko0dIxgRlqISDAohdrEptHpcVujsD
ak88LLnAurr60JNjWX2wbEoJ18KLtqGSnATdmCgKwDPIN5a7+ssp44BGyzH6VYCg
wV3VjJl0pp4C5M0Jfu0p1FrQjzIVhgqR7JFYmvqIogWrGwYAQK/3XRXq8t48J5X3
IsfWiLAA2ZdCoWAnZ6PAGBOoGivtkJm967pHjd/28qYY6mQo4sN2ksEOjx6/YslF
2mOJdLV/DzqoggUsTpPEG0dRhzQLTGHKDQ==
-----END CERTIFICATE-----

28
apps/client/localhost.pem

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCdIUY1YopBClSM
rpF0eAn7mFbxRBcwegMAEPSa89jLHqqS2nC01xRQEueJqYg7qjwc7PbWuNJpUq6k
gbgFiopKVuZEzZ5qlJw2eokiXs22FsfWMlCxqqm9atwuP+aR/ZujRyftrmlDSpQ8
WDSm57arN3a0BHcR/cr+u9med576IE3H/Y8+s47rREBklR4FZAk00Js39/t7LflS
eQ/CXmBUfraWespXqoFxWkzLT2UXyi705EPinsgiS57FCQUL3QiiNDxrBunesoFx
Zknj6JQ/Slt1UyigzwrJ3SA/PbwJ0Vg4/VIc78nMZiqXBRq1Dx+fj4rNTzNUezpw
L5xcoJ8hAgMBAAECggEAPU5YOEgELTA8oM8TjV+wdWuQsH2ilpVkSkhTR4nQkh+a
6cU0qDoqgLt/fySYNL9MyPRjso9V+SX7YdAC3paZMjwJh9q57lehQ1g33SMkG+Fz
gs0K0ucFZxQkaB8idN+ANAp1N7UO+ORGRe0cTeqmSNNRCxea5XgiFZVxaPS/IFOR
vVdXS1DulgwHh4H0ljKmkj7jc9zPBSc9ccW5Ml2q4a26Atu4IC/Mlp/DF4GNELbD
ebi9ZOZG33ip2bdhj3WX7NW9GJaaViKtVUpcpR6u+6BfvTXQ6xrqdoxXk9fnPzzf
sSoLPTt8yO4RavP1zQU22PhgIcWudhCiy/2Nv+uLqQKBgQDMPh1/xwdFl8yES8dy
f0xJyCDWPiB+xzGwcOAC90FrNZaqG0WWs3VHE4cilaWjCflvdR6aAEDEY68sZJhl
h+ChT/g61QLMOI+VIDQ1kJXKYgfS/B+BE1PZ0EkuymKFOvbNO8agHodB8CqnZaQh
bLuZaDnZ0JLK4im3KPt70+aKYwKBgQDE8s6xl0SLu+yJLo3VklCRD67Z8/jlvx2t
h3DF6NG8pB3QmvKdJWFKuLAWLGflJwbUW9Pt3hXkc0649KQrtiIYC0ZMh9KMaVCk
WmjS/n2eIUQZ7ZUlwFesi4p4iGynVBavIY8TJ6Y+K3TvsJgXP3IZ96r689PQJo8E
KbSeyYzFqwKBgGQTS4EAlJ+U8bEhMGj51veQCAbyChoUoFRD+n95h6RwbZKMKlzd
MenRt7VKfg6VJJNoX8Y1uYaBEaQ+5i1Zlsdz1718AhLu4+u+C9bzMXIo9ox63TTx
s3RWioVSxVNiwOtvDrQGQWAdvcioFPQLwyA34aDIgiTHDImimxbhjWThAoGAWOqW
Tq9QjxWk0Lpn5ohMP3GpK1VuhasnJvUDARb/uf8ORuPtrOz3Y9jGBvy9W0OnXbCn
mbiugZldbTtl8yYjdl+AuYSIlkPl2I3IzZl/9Shnqp0MvSJ9crT9KzXMeC8Knr6z
7Z30/BR6ksxTngtS5E5grzPt6Qe/gc2ich3kpEkCgYBfBHUhVIKVrDW/8a7U2679
Wj4i9us/M3iPMxcTv7/ZAg08TEvNBQYtvVQLu0NfDKXx8iGKJJ6evIYgNXVm2sPq
VzSgwGCALi1X0443amZU+mnX+/9RnBqyM+PJU8570mM83jqKlY/BEsi0aqxTioFG
an3xbjjN+Rq9iKLzmPxIMg==
-----END PRIVATE KEY-----

5
apps/client/project.json

@ -163,8 +163,11 @@
"serve": { "serve": {
"executor": "@nx/angular:dev-server", "executor": "@nx/angular:dev-server",
"options": { "options": {
"buildTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json", "proxyConfig": "apps/client/proxy.conf.json",
"buildTarget": "client:build" "ssl": true,
"sslCert": "apps/client/localhost.cert",
"sslKey": "apps/client/localhost.pem"
}, },
"configurations": { "configurations": {
"development-de": { "development-de": {

50
apps/client/src/app/app-routing.module.ts

@ -1,3 +1,5 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { paths } from '@ghostfolio/client/core/paths';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy'; import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
@ -5,18 +7,6 @@ import { RouterModule, Routes, TitleStrategy } from '@angular/router';
import { ModulePreloadService } from './core/module-preload.service'; import { ModulePreloadService } from './core/module-preload.service';
export const paths = {
about: $localize`about`,
faq: $localize`faq`,
features: $localize`features`,
license: $localize`license`,
markets: $localize`markets`,
pricing: $localize`pricing`,
privacyPolicy: $localize`privacy-policy`,
register: $localize`register`,
resources: $localize`resources`
};
const routes: Routes = [ const routes: Routes = [
{ {
path: paths.about, path: paths.about,
@ -53,9 +43,12 @@ const routes: Routes = [
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
}, },
{ {
path: 'demo', canActivate: [AuthGuard],
loadChildren: () => loadComponent: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) import('./pages/demo/demo-page.component').then(
(c) => c.GfDemoPageComponent
),
path: 'demo'
}, },
{ {
path: paths.faq, path: paths.faq,
@ -63,11 +56,13 @@ const routes: Routes = [
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
}, },
{ {
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/features/features-page.component').then(
(c) => c.GfFeaturesPageComponent
),
path: paths.features, path: paths.features,
loadChildren: () => title: $localize`Features`
import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule
)
}, },
{ {
path: 'home', path: 'home',
@ -75,9 +70,13 @@ const routes: Routes = [
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{ {
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/i18n/i18n-page.component').then(
(c) => c.GfI18nPageComponent
),
path: 'i18n', path: 'i18n',
loadChildren: () => title: $localize`Internationalization`
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
}, },
{ {
path: paths.markets, path: paths.markets,
@ -134,11 +133,12 @@ const routes: Routes = [
) )
}, },
{ {
loadComponent: () =>
import('./pages/webauthn/webauthn-page.component').then(
(c) => c.GfWebauthnPageComponent
),
path: 'webauthn', path: 'webauthn',
loadChildren: () => title: $localize`Sign in`
import('./pages/webauthn/webauthn-page.module').then(
(m) => m.WebauthnPageModule
)
}, },
{ {
path: 'zen', path: 'zen',

57
apps/client/src/app/app.component.html

@ -1,15 +1,10 @@
<header> <header>
<div @if (canCreateAccount || user?.systemMessage) {
*ngIf="canCreateAccount || user?.systemMessage" <div class="info-message-container">
class="info-message-container"
>
<div class="info-message-inner-container position-fixed w-100"> <div class="info-message-inner-container position-fixed w-100">
<div class="align-items-center d-flex h-100 justify-content-center"> <div class="align-items-center d-flex h-100 justify-content-center">
<a @if (canCreateAccount) {
*ngIf="canCreateAccount" <a class="text-center" [routerLink]="routerLinkRegister">
class="text-center"
[routerLink]="routerLinkRegister"
>
<div <div
class="cursor-pointer d-inline-block info-message" class="cursor-pointer d-inline-block info-message"
(click)="onCreateAccount()" (click)="onCreateAccount()"
@ -18,16 +13,19 @@
<span class="a ml-2" i18n>Create Account</span> <span class="a ml-2" i18n>Create Account</span>
</div></a </div></a
> >
}
@if (!canCreateAccount && user?.systemMessage) {
<div <div
*ngIf="!canCreateAccount && user?.systemMessage"
class="cursor-pointer d-inline-block info-message text-truncate" class="cursor-pointer d-inline-block info-message text-truncate"
(click)="onClickSystemMessage()" (click)="onClickSystemMessage()"
> >
{{ user.systemMessage.message }} {{ user.systemMessage.message }}
</div> </div>
}
</div> </div>
</div> </div>
</div> </div>
}
<gf-header <gf-header
class="position-fixed w-100" class="position-fixed w-100"
@ -45,7 +43,8 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer *ngIf="showFooter" class="d-flex justify-content-center py-4 w-100"> @if (showFooter) {
<footer class="d-flex justify-content-center py-4 w-100">
<div class="container"> <div class="container">
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-sm"> <div class="col-sm">
@ -54,9 +53,11 @@
<div class="col-sm"> <div class="col-sm">
<div class="h6 mt-2" i18n>Personal Finance</div> <div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li *ngIf="hasPermissionToAccessFearAndGreedIndex"> @if (hasPermissionToAccessFearAndGreedIndex) {
<li>
<a i18n [routerLink]="routerLinkMarkets">Markets</a> <a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li> </li>
}
<li><a i18n [routerLink]="routerLinkResources">Resources</a></li> <li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
</ul> </ul>
</div> </div>
@ -64,33 +65,44 @@
<div class="h6 mt-2">Ghostfolio</div> <div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a i18n [routerLink]="routerLinkAbout">About</a></li> <li><a i18n [routerLink]="routerLinkAbout">About</a></li>
<li *ngIf="hasPermissionForSubscription"> @if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
}
<li> <li>
<a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a> <a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li> </li>
<li><a i18n [routerLink]="routerLinkFeatures">Features</a></li> <li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
<li *ngIf="hasPermissionForSubscription"> @if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkFaq" <a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a >Frequently Asked Questions (FAQ)</a
> >
</li> </li>
}
<li> <li>
<a i18n [routerLink]="routerLinkAboutLicense">License</a> <a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li> </li>
<li *ngIf="hasPermissionForStatistics"> @if (hasPermissionForStatistics) {
<li>
<a [routerLink]="['/open']">Open Startup</a> <a [routerLink]="['/open']">Open Startup</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> }
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkPricing">Pricing</a> <a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> }
@if (hasPermissionForSubscription) {
<li>
<a i18n [routerLink]="routerLinkAboutPrivacyPolicy" <a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a >Privacy Policy</a
> >
</li> </li>
<li *ngIf="hasPermissionForSubscription"> }
@if (hasPermissionForSubscription) {
<li>
<a <a
class="align-items-baseline d-flex" class="align-items-baseline d-flex"
href="https://status.ghostfol.io" href="https://status.ghostfol.io"
@ -99,6 +111,7 @@
>Status<ion-icon class="ml-1" name="open-outline" >Status<ion-icon class="ml-1" name="open-outline"
/></a> /></a>
</li> </li>
}
</ul> </ul>
</div> </div>
<div class="col-sm"> <div class="col-sm">
@ -158,11 +171,9 @@
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>
<!--
<li> <li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a> <a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li> </li>
-->
<!-- <!--
<li> <li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a> <a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
@ -171,13 +182,12 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="row text-center"> <div class="row text-center">
<div class="col"> <div class="col">
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a> © 2021 - {{ currentYear }}
<a href="https://ghostfol.io">Ghostfolio</a>
</div> </div>
</div> </div>
<div class="row text-center text-muted"> <div class="row text-center text-muted">
<div class="col"> <div class="col">
<small i18n <small i18n
@ -188,3 +198,4 @@
</div> </div>
</div> </div>
</footer> </footer>
}

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

@ -1,3 +1,5 @@
import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding-detail-dialog/interfaces/interfaces';
import { getCssVariable } from '@ghostfolio/common/helper'; import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -13,13 +15,21 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router'; import {
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router
} from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service'; import { UserService } from './services/user/user.service';
@ -38,6 +48,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean; public hasInfoMessage: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
@ -67,7 +78,10 @@ export class AppComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router, private router: Router,
private title: Title, private title: Title,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
@ -75,6 +89,21 @@ export class AppComponent implements OnDestroy, OnInit {
) { ) {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['holdingDetailDialog'] &&
params['symbol']
) {
this.openHoldingDetailDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
} }
public ngOnInit() { public ngOnInit() {
@ -96,6 +125,13 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => { .subscribe(() => {
@ -197,6 +233,55 @@ export class AppComponent implements OnDestroy, OnInit {
}); });
} }
private openHoldingDetailDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
autoFocus: false,
data: <HoldingDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate([], {
queryParams: {
dataSource: null,
holdingDetailDialog: null,
symbol: null
},
queryParamsHandling: 'merge',
relativeTo: this.route
});
});
});
}
private toggleTheme(isDarkTheme: boolean) { private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable( const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background' isDarkTheme ? '--dark-background' : '--light-background'

7
apps/client/src/app/app.module.ts

@ -1,7 +1,10 @@
import { GfLogoComponent } from '@ghostfolio/ui/logo'; import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http'; import {
provideHttpClient,
withInterceptorsFromDi
} from '@angular/common/http';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
@ -45,7 +48,6 @@ export function NgxStripeFactory(): string {
GfHeaderModule, GfHeaderModule,
GfLogoComponent, GfLogoComponent,
GfSubscriptionInterstitialDialogModule, GfSubscriptionInterstitialDialogModule,
HttpClientModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),
MatAutocompleteModule, MatAutocompleteModule,
MatChipsModule, MatChipsModule,
@ -63,6 +65,7 @@ export function NgxStripeFactory(): string {
authInterceptorProviders, authInterceptorProviders,
httpResponseInterceptorProviders, httpResponseInterceptorProviders,
LanguageService, LanguageService,
provideHttpClient(withInterceptorsFromDi()),
{ {
provide: DateAdapter, provide: DateAdapter,
useClass: CustomDateAdapter, useClass: CustomDateAdapter,

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

@ -1,3 +1,4 @@
<div class="overflow-x-auto">
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
@ -31,7 +32,8 @@
<ng-container matColumnDef="details"> <ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div *ngIf="element.type === 'PUBLIC'" class="align-items-center d-flex"> @if (element.type === 'PUBLIC') {
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline" /> <ion-icon class="mr-1" name="link-outline" />
<a <a
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}" href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
@ -39,6 +41,7 @@
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a >{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
> >
</div> </div>
}
</td> </td>
</ng-container> </ng-container>
@ -65,3 +68,4 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
</div>

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

@ -1,3 +1,4 @@
import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -95,19 +96,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onAddAccountBalance({ public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
balance,
date
}: {
balance: number;
date: Date;
}) {
this.dataService this.dataService
.postAccountBalance({ .postAccountBalance(accountBalance)
balance,
date,
accountId: this.data.accountId
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.fetchAccount(); this.fetchAccount();

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

@ -94,6 +94,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView !data.hasImpersonationId && !user.settings.isRestrictedView
" "

46
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -1,4 +1,5 @@
<div *ngIf="showActions" class="d-flex justify-content-end"> @if (showActions) {
<div class="d-flex justify-content-end">
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-stroked-button mat-stroked-button
@ -9,7 +10,9 @@
<ng-container i18n>Transfer Cash Balance</ng-container>... <ng-container i18n>Transfer Cash Balance</ng-container>...
</button> </button>
</div> </div>
}
<div class="overflow-x-auto">
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource"> <table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th <th
@ -17,9 +20,15 @@
class="d-none d-lg-table-cell px-1" class="d-none d-lg-table-cell px-1"
mat-header-cell mat-header-cell
></th> ></th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline" /> @if (element.isExcluded) {
<ion-icon name="eye-off-outline" />
}
</div> </div>
</td> </td>
<td <td
@ -34,12 +43,13 @@
<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>
@if (element.Platform?.url) {
<gf-asset-profile-icon <gf-asset-profile-icon
*ngIf="element.Platform?.url"
class="d-inline d-sm-none mr-1" class="d-inline d-sm-none mr-1"
[tooltip]="element.Platform?.name" [tooltip]="element.Platform?.name"
[url]="element.Platform?.url" [url]="element.Platform?.url"
/> />
}
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
@ -54,7 +64,11 @@
> >
<ng-container i18n>Currency</ng-container> <ng-container i18n>Currency</ng-container>
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
{{ element.currency }} {{ element.currency }}
</td> </td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell> <td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
@ -71,14 +85,19 @@
> >
<ng-container i18n>Platform</ng-container> <ng-container i18n>Platform</ng-container>
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex"> <div class="d-flex">
@if (element.Platform?.url) {
<gf-asset-profile-icon <gf-asset-profile-icon
*ngIf="element.Platform?.url"
class="mr-1" class="mr-1"
[tooltip]="element.Platform?.name" [tooltip]="element.Platform?.name"
[url]="element.Platform?.url" [url]="element.Platform?.url"
/> />
}
<span>{{ element.Platform?.name }}</span> <span>{{ element.Platform?.name }}</span>
</div> </div>
</td> </td>
@ -218,9 +237,13 @@
class="d-none d-lg-table-cell px-1" class="d-none d-lg-table-cell px-1"
mat-header-cell mat-header-cell
></th> ></th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
@if (element.comment) {
<button <button
*ngIf="element.comment"
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
title="Note" title="Note"
@ -228,6 +251,7 @@
> >
<ion-icon name="document-text-outline" /> <ion-icon name="document-text-outline" />
</button> </button>
}
</td> </td>
<td <td
*matFooterCellDef *matFooterCellDef
@ -284,9 +308,10 @@
[ngClass]="{ 'd-none': isLoading || !showFooter }" [ngClass]="{ 'd-none': isLoading || !showFooter }"
></tr> ></tr>
</table> </table>
</div>
@if (isLoading) {
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse" animation="pulse"
class="px-4 py-3" class="px-4 py-3"
[theme]="{ [theme]="{
@ -294,3 +319,4 @@
width: '100%' width: '100%'
}" }"
/> />
}

2
apps/client/src/app/components/accounts-table/accounts-table.component.scss

@ -1,7 +1,7 @@
:host { :host {
display: block; display: block;
.mat-mdc-table { .gf-table {
th { th {
::ng-deep { ::ng-deep {
.mat-sort-header-container { .mat-sort-header-container {

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

@ -5,11 +5,14 @@
<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">
<mat-option /> <mat-option />
<mat-option @for (
*ngFor="let statusFilterOption of statusFilterOptions" statusFilterOption of statusFilterOptions;
[value]="statusFilterOption" track statusFilterOption
>{{ statusFilterOption }}</mat-option ) {
> <mat-option [value]="statusFilterOption">{{
statusFilterOption
}}</mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</form> </form>
@ -28,15 +31,11 @@
<ng-container i18n>Type</ng-container> <ng-container i18n>Type</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell> <td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n> @if (element.name === 'GATHER_ASSET_PROFILE') {
Asset Profile <ng-container i18n>Asset Profile</ng-container>
</ng-container> } @else if (element.name === 'GATHER_HISTORICAL_MARKET_DATA') {
<ng-container <ng-container i18n>Historical Market Data</ng-container>
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'" }
i18n
>
Historical Market Data
</ng-container>
</td> </td>
</ng-container> </ng-container>
@ -109,37 +108,29 @@
<ng-container i18n>Status</ng-container> <ng-container i18n>Status</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell> <td *matCellDef="let element" class="px-1 py-2" mat-cell>
@if (element.state === 'active') {
<ion-icon class="h6 mb-0" name="play-outline" />
} @else if (element.state === 'completed') {
<ion-icon <ion-icon
*ngIf="element.state === 'active'"
class="h6 mb-0"
name="play-outline"
/>
<ion-icon
*ngIf="element.state === 'completed'"
class="h6 mb-0 text-success" class="h6 mb-0 text-success"
name="checkmark-circle-outline" name="checkmark-circle-outline"
/> />
} @else if (element.state === 'delayed') {
<ion-icon <ion-icon
*ngIf="element.state === 'delayed'"
class="h6 mb-0" class="h6 mb-0"
name="time-outline" name="time-outline"
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }" [ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
/> />
} @else if (element.state === 'failed') {
<ion-icon <ion-icon
*ngIf="element.state === 'failed'"
class="h6 mb-0 text-danger" class="h6 mb-0 text-danger"
name="alert-circle-outline" name="alert-circle-outline"
/> />
<ion-icon } @else if (element.state === 'paused') {
*ngIf="element.state === 'paused'" <ion-icon class="h6 mb-0" name="pause-outline" />
class="h6 mb-0" } @else if (element.state === 'waiting') {
name="pause-outline" <ion-icon class="h6 mb-0" name="cafe-outline" />
/> }
<ion-icon
*ngIf="element.state === 'waiting'"
class="h6 mb-0"
name="cafe-outline"
/>
</td> </td>
</ng-container> </ng-container>

7
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html

@ -9,11 +9,12 @@
[showYAxis]="true" [showYAxis]="true"
[symbol]="symbol" [symbol]="symbol"
/> />
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex"> @for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) {
<div class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div> <div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1"> <div class="align-items-center d-flex flex-grow-1 px-1">
@for (dayItem of days; track dayItem; let i = $index) {
<div <div
*ngFor="let dayItem of days; let i = index"
class="day" class="day"
[ngClass]="{ [ngClass]="{
'cursor-pointer valid': isDateOfInterest( 'cursor-pointer valid': isDateOfInterest(
@ -38,6 +39,8 @@
}) })
" "
></div> ></div>
}
</div> </div>
</div> </div>
}
</div> </div>

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

@ -38,6 +38,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
selector: 'gf-admin-market-data', selector: 'gf-admin-market-data',
styleUrls: ['./admin-market-data.scss'], styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'

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

@ -39,9 +39,13 @@
</th> </th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell> <td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<div class="text-truncate">{{ element.name }}</div> <div class="text-truncate">{{ element.name }}</div>
<div *ngIf="!isUUID(element.symbol)"> @if (!isUUID(element.symbol)) {
<small class="text-muted">{{ element.symbol | gfSymbol }}</small> <div>
<small class="text-muted">{{
element.symbol | gfSymbol
}}</small>
</div> </div>
}
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td> <td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
@ -121,11 +125,9 @@
<ng-container matColumnDef="comment"> <ng-container matColumnDef="comment">
<th *matHeaderCellDef class="px-1" mat-header-cell></th> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<ion-icon @if (element.comment) {
*ngIf="element.comment" <ion-icon class="d-block" name="document-text-outline" />
class="d-block" }
name="document-text-outline"
/>
</td> </td>
</ng-container> </ng-container>
@ -222,8 +224,8 @@
(page)="onChangePage($event)" (page)="onChangePage($event)"
/> />
@if (isLoading && totalItems === 0) {
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="isLoading && totalItems === 0"
animation="pulse" animation="pulse"
class="px-4 py-3" class="px-4 py-3"
[theme]="{ [theme]="{
@ -231,6 +233,7 @@
width: '100%' width: '100%'
}" }"
/> />
}
</div> </div>
</div> </div>

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

@ -8,7 +8,6 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
Currency,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -73,7 +72,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public currencies: Currency[] = []; public currencies: string[] = [];
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public isBenchmark = false; public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
@ -102,10 +101,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
const { benchmarks, currencies } = this.dataService.fetchInfo(); const { benchmarks, currencies } = this.dataService.fetchInfo();
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.currencies = currencies.map((currency) => ({ this.currencies = currencies;
label: currency,
value: currency
}));
this.initialize(); this.initialize();
} }
@ -293,9 +289,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfileForm.get('assetClass').value, assetClass: this.assetProfileForm.get('assetClass').value,
assetSubClass: this.assetProfileForm.get('assetSubClass').value, assetSubClass: this.assetProfileForm.get('assetSubClass').value,
comment: this.assetProfileForm.get('comment').value || null, comment: this.assetProfileForm.get('comment').value || null,
currency: (<Currency>( currency: this.assetProfileForm.get('currency').value,
(<unknown>this.assetProfileForm.get('currency').value)
))?.value,
name: this.assetProfileForm.get('name').value, name: this.assetProfileForm.get('name').value,
url: this.assetProfileForm.get('url').value || null url: this.assetProfileForm.get('url').value || null
}; };
@ -343,8 +337,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
' ' + ' ' +
price + price +
' ' + ' ' +
(<Currency>(<unknown>this.assetProfileForm.get('currency').value)) this.assetProfileForm.get('currency').value
?.value
); );
}); });
} }

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

@ -115,11 +115,22 @@
>Symbol</gf-value >Symbol</gf-value
> >
</div> </div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataSource
"
>Data Source</gf-value
>
</div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.currency" <gf-value i18n size="medium" [value]="assetProfile?.currency"
>Currency</gf-value >Currency</gf-value
> >
</div> </div>
<div class="col-6 mb-3"></div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
@ -232,11 +243,11 @@
<mat-label i18n>Asset Class</mat-label> <mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass"> <mat-select formControlName="assetClass">
<mat-option [value]="null" /> <mat-option [value]="null" />
<mat-option @for (assetClass of assetClasses; track assetClass) {
*ngFor="let assetClass of assetClasses" <mat-option [value]="assetClass.id">{{
[value]="assetClass.id" assetClass.label
>{{ assetClass.label }}</mat-option }}</mat-option>
> }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -245,11 +256,11 @@
<mat-label i18n>Asset Sub Class</mat-label> <mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass"> <mat-select formControlName="assetSubClass">
<mat-option [value]="null" /> <mat-option [value]="null" />
<mat-option @for (assetSubClass of assetSubClasses; track assetSubClass) {
*ngFor="let assetSubClass of assetSubClasses" <mat-option [value]="assetSubClass.id">{{
[value]="assetSubClass.id" assetSubClass.label
>{{ assetSubClass.label }}</mat-option }}</mat-option>
> }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>

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

@ -20,7 +20,8 @@
</mat-radio-group> </mat-radio-group>
</div> </div>
<div *ngIf="mode === 'auto'"> @if (mode === 'auto') {
<div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label> <mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete <gf-symbol-autocomplete
@ -29,12 +30,14 @@
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="mode === 'manual'"> } @else if (mode === 'manual') {
<div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol</mat-label> <mat-label i18n>Symbol</mat-label>
<input formControlName="addSymbol" matInput /> <input formControlName="addSymbol" matInput />
</mat-form-field> </mat-form-field>
</div> </div>
}
</div> </div>
<div class="d-flex justify-content-end" mat-dialog-actions> <div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>

37
apps/client/src/app/components/admin-overview/admin-overview.html

@ -27,17 +27,20 @@
[precision]="0" [precision]="0"
[value]="transactionCount" [value]="transactionCount"
/> />
<div *ngIf="transactionCount && userCount"> @if (transactionCount && userCount) {
<div>
{{ transactionCount / userCount | number: '1.2-2' }} {{ transactionCount / userCount | number: '1.2-2' }}
<span i18n>per User</span> <span i18n>per User</span>
</div> </div>
}
</div> </div>
</div> </div>
<div class="align-items-start d-flex my-3"> <div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Exchange Rates</div> <div class="w-50" i18n>Exchange Rates</div>
<div class="w-50"> <div class="w-50">
<table> <table>
<tr *ngFor="let exchangeRate of exchangeRates"> @for (exchangeRate of exchangeRates; track exchangeRate) {
<tr>
<td> <td>
<gf-value [locale]="user?.settings?.locale" [value]="1" /> <gf-value [locale]="user?.settings?.locale" [value]="1" />
</td> </td>
@ -80,8 +83,8 @@
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</a> </a>
@if (customCurrencies.includes(exchangeRate.label2)) {
<button <button
*ngIf="customCurrencies.includes(exchangeRate.label2)"
mat-menu-item mat-menu-item
(click)="onDeleteCurrency(exchangeRate.label2)" (click)="onDeleteCurrency(exchangeRate.label2)"
> >
@ -90,9 +93,11 @@
<span i18n>Delete</span> <span i18n>Delete</span>
</span> </span>
</button> </button>
}
</mat-menu> </mat-menu>
</td> </td>
</tr> </tr>
}
</table> </table>
<div class="mt-2"> <div class="mt-2">
<button <button
@ -119,7 +124,8 @@
/> />
</div> </div>
</div> </div>
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3"> @if (hasPermissionToToggleReadOnlyMode) {
<div class="d-flex my-3">
<div class="w-50" i18n>Read-only Mode</div> <div class="w-50" i18n>Read-only Mode</div>
<div class="w-50"> <div class="w-50">
<mat-slide-toggle <mat-slide-toggle
@ -130,6 +136,7 @@
/> />
</div> </div>
</div> </div>
}
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Data Gathering</div> <div class="w-50" i18n>Data Gathering</div>
<div class="w-50"> <div class="w-50">
@ -141,10 +148,12 @@
/> />
</div> </div>
</div> </div>
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3"> @if (hasPermissionForSystemMessage) {
<div class="d-flex my-3">
<div class="w-50" i18n>System Message</div> <div class="w-50" i18n>System Message</div>
<div class="w-50"> <div class="w-50">
<div *ngIf="systemMessage" class="align-items-center d-flex"> @if (systemMessage) {
<div class="align-items-center d-flex">
<div class="text-truncate">{{ systemMessage | json }}</div> <div class="text-truncate">{{ systemMessage | json }}</div>
<button <button
class="h-100 mx-1 no-min-width px-2" class="h-100 mx-1 no-min-width px-2"
@ -154,8 +163,9 @@
<ion-icon name="trash-outline" /> <ion-icon name="trash-outline" />
</button> </button>
</div> </div>
}
@if (!info?.systemMessage) {
<button <button
*ngIf="!info?.systemMessage"
class="mt-2" class="mt-2"
color="accent" color="accent"
mat-flat-button mat-flat-button
@ -164,16 +174,17 @@
<ion-icon class="mr-1" name="information-circle-outline" /> <ion-icon class="mr-1" name="information-circle-outline" />
<span i18n>Set Message</span> <span i18n>Set Message</span>
</button> </button>
}
</div> </div>
</div> </div>
<div }
*ngIf="hasPermissionForSubscription" @if (hasPermissionForSubscription) {
class="d-flex my-3 subscription" <div class="d-flex my-3 subscription">
>
<div class="w-50" i18n>Coupons</div> <div class="w-50" i18n>Coupons</div>
<div class="w-50"> <div class="w-50">
<table> <table>
<tr *ngFor="let coupon of coupons"> @for (coupon of coupons; track coupon) {
<tr>
<td class="text-monospace">{{ coupon.code }}</td> <td class="text-monospace">{{ coupon.code }}</td>
<td class="pl-2 text-right">{{ coupon.duration }}</td> <td class="pl-2 text-right">{{ coupon.duration }}</td>
<td> <td>
@ -202,6 +213,7 @@
</mat-menu> </mat-menu>
</td> </td>
</tr> </tr>
}
</table> </table>
<div class="mt-2"> <div class="mt-2">
<form #couponForm="ngForm" class="align-items-center d-flex"> <form #couponForm="ngForm" class="align-items-center d-flex">
@ -234,6 +246,7 @@
</div> </div>
</div> </div>
</div> </div>
}
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Housekeeping</div> <div class="w-50" i18n>Housekeeping</div>
<div class="w-50"> <div class="w-50">

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

@ -30,12 +30,13 @@
<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>
@if (element.url) {
<gf-asset-profile-icon <gf-asset-profile-icon
*ngIf="element.url"
class="d-inline mr-1" class="d-inline mr-1"
[tooltip]="element.name" [tooltip]="element.name"
[url]="element.url" [url]="element.url"
/> />
}
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
</td></ng-container </td></ng-container
> >

7
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html

@ -4,8 +4,11 @@
(keyup.enter)="platformForm.valid && onSubmit()" (keyup.enter)="platformForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 *ngIf="data.platform.id" i18n mat-dialog-title>Update platform</h1> @if (data.platform.id) {
<h1 *ngIf="!data.platform.id" i18n mat-dialog-title>Add platform</h1> <h1 i18n mat-dialog-title>Update platform</h1>
} @else {
<h1 i18n mat-dialog-title>Add platform</h1>
}
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">

7
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html

@ -4,8 +4,11 @@
(keyup.enter)="tagForm.valid && onSubmit()" (keyup.enter)="tagForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1> @if (data.tag.id) {
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1> <h1 i18n mat-dialog-title>Update tag</h1>
} @else {
<h1 i18n mat-dialog-title>Add tag</h1>
}
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">

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

@ -1,7 +1,7 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="users"> <div class="overflow-x-auto">
<table class="gf-table" mat-table [dataSource]="dataSource"> <table class="gf-table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="index"> <ng-container matColumnDef="index">
<th <th
@ -49,26 +49,26 @@
}" }"
>{{ (element.id | slice: 0 : 5) + '...' }}</span >{{ (element.id | slice: 0 : 5) + '...' }}</span
> >
@if (element?.subscription?.type === 'Premium') {
<gf-premium-indicator <gf-premium-indicator
*ngIf="element?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false" [enableLink]="false"
[title]=" [title]="
'Expires ' + 'Expires ' +
formatDistanceToNow(element.subscription.expiresAt) + formatDistanceToNow(element.subscription.expiresAt) +
' (' + ' (' +
(element.subscription.expiresAt | date: defaultDateFormat) + (element.subscription.expiresAt
| date: defaultDateFormat) +
')' ')'
" "
/> />
}
</div> </div>
</td> </td>
</ng-container> </ng-container>
<ng-container @if (hasPermissionForSubscription) {
*ngIf="hasPermissionForSubscription" <ng-container matColumnDef="country">
matColumnDef="country"
>
<th <th
*matHeaderCellDef *matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2" class="mat-mdc-header-cell px-1 py-2"
@ -86,6 +86,7 @@
}}</span> }}</span>
</td> </td>
</ng-container> </ng-container>
}
<ng-container matColumnDef="registration"> <ng-container matColumnDef="registration">
<th <th
@ -146,10 +147,8 @@
</td> </td>
</ng-container> </ng-container>
<ng-container @if (hasPermissionForSubscription) {
*ngIf="hasPermissionForSubscription" <ng-container matColumnDef="engagementPerDay">
matColumnDef="engagementPerDay"
>
<th <th
*matHeaderCellDef *matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right" class="mat-mdc-header-cell px-1 py-2 text-right"
@ -170,11 +169,10 @@
/> />
</td> </td>
</ng-container> </ng-container>
}
<ng-container @if (hasPermissionForSubscription) {
*ngIf="hasPermissionForSubscription" <ng-container matColumnDef="lastRequest">
matColumnDef="lastRequest"
>
<th <th
*matHeaderCellDef *matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2" class="mat-mdc-header-cell px-1 py-2"
@ -191,6 +189,7 @@
{{ formatDistanceToNow(element.lastActivity) }} {{ formatDistanceToNow(element.lastActivity) }}
</td> </td>
</ng-container> </ng-container>
}
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th <th
@ -212,16 +211,14 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #userMenu="matMenu" xPosition="before"> <mat-menu #userMenu="matMenu" xPosition="before">
<button @if (hasPermissionToImpersonateAllUsers) {
*ngIf="hasPermissionToImpersonateAllUsers" <button mat-menu-item (click)="onImpersonateUser(element.id)">
mat-menu-item
(click)="onImpersonateUser(element.id)"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="contract-outline" /> <ion-icon class="mr-2" name="contract-outline" />
<span i18n>Impersonate User</span> <span i18n>Impersonate User</span>
</span> </span>
</button> </button>
}
<button <button
mat-menu-item mat-menu-item
[disabled]="element.id === user?.id" [disabled]="element.id === user?.id"

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

@ -1,10 +1,7 @@
:host { :host {
display: block; display: block;
.users { .gf-table {
overflow-x: auto;
table {
min-width: 100%; min-width: 100%;
.mat-mdc-row, .mat-mdc-row,
@ -13,4 +10,3 @@
} }
} }
} }
}

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

@ -4,10 +4,9 @@
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate" class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
> >
<span i18n>Performance</span> <span i18n>Performance</span>
<gf-premium-indicator @if (user?.subscription?.type === 'Basic') {
*ngIf="user?.subscription?.type === 'Basic'" <gf-premium-indicator class="ml-1" />
class="ml-1" }
/>
</div> </div>
</div> </div>
<div class="col-md-6 col-xs-12 d-flex justify-content-end"> <div class="col-md-6 col-xs-12 d-flex justify-content-end">
@ -24,33 +23,33 @@
(selectionChange)="onChangeBenchmark($event.value)" (selectionChange)="onChangeBenchmark($event.value)"
> >
<mat-option [value]="null" /> <mat-option [value]="null" />
<mat-option @for (symbolProfile of benchmarks; track symbolProfile) {
*ngFor="let symbolProfile of benchmarks" <mat-option [value]="symbolProfile.id">{{
[value]="symbolProfile.id" symbolProfile.name
>{{ symbolProfile.name }}</mat-option }}</mat-option>
> }
<mat-option @if (hasPermissionToAccessAdminControl) {
*ngIf="hasPermissionToAccessAdminControl" <mat-option [routerLink]="['/admin', 'market-data']">
[routerLink]="['/admin', 'market-data']"
>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<ion-icon class="mr-2 text-muted" name="arrow-forward-outline" /> <ion-icon class="mr-2 text-muted" name="arrow-forward-outline" />
<span i18n>Manage Benchmarks</span> <span i18n>Manage Benchmarks</span>
</div> </div>
</mat-option> </mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="chart-container"> <div class="chart-container">
@if (isLoading) {
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse" animation="pulse"
[theme]="{ [theme]="{
height: '100%', height: '100%',
width: '100%' width: '100%'
}" }"
/> />
}
<canvas <canvas
#chartCanvas #chartCanvas
class="h-100" class="h-100"

8
apps/client/src/app/components/dialog-footer/dialog-footer.component.html

@ -1,7 +1,5 @@
<button @if (deviceType === 'mobile') {
*ngIf="deviceType === 'mobile'" <button mat-button (click)="onClickCloseButton()">
mat-button
(click)="onClickCloseButton()"
>
<ion-icon name="close" size="large" /> <ion-icon name="close" size="large" />
</button> </button>
}

9
apps/client/src/app/components/dialog-header/dialog-header.component.html

@ -3,11 +3,8 @@
[ngClass]="{ 'text-center': position === 'center' }" [ngClass]="{ 'text-center': position === 'center' }"
>{{ title }}</span >{{ title }}</span
> >
<button @if (deviceType !== 'mobile') {
*ngIf="deviceType !== 'mobile'" <button class="no-min-width px-0" mat-button (click)="onClickCloseButton()">
class="no-min-width px-0"
mat-button
(click)="onClickCloseButton()"
>
<ion-icon name="close" size="large" /> <ion-icon name="close" size="large" />
</button> </button>
}

3
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

@ -12,12 +12,13 @@
<small class="d-block" i18n>Current Market Mood</small> <small class="d-block" i18n>Current Market Mood</small>
</div> </div>
</div> </div>
@if (!fearAndGreedIndex) {
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="!fearAndGreedIndex"
animation="pulse" animation="pulse"
class="position-absolute w-100" class="position-absolute w-100"
[theme]="{ [theme]="{
height: '100%' height: '100%'
}" }"
/> />
}
</div> </div>

109
apps/client/src/app/components/header/header.component.html

@ -1,5 +1,5 @@
<mat-toolbar class="px-0"> <mat-toolbar class="px-0">
<ng-container *ngIf="user"> @if (user) {
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }"> <div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a <a
class="align-items-center justify-content-start rounded-0" class="align-items-center justify-content-start rounded-0"
@ -54,7 +54,8 @@
>Accounts</a >Accounts</a
> >
</li> </li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item"> @if (hasPermissionToAccessAdminControl) {
<li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
i18n i18n
@ -67,6 +68,7 @@
>Admin Control</a >Admin Control</a
> >
</li> </li>
}
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
@ -80,12 +82,10 @@
>Resources</a >Resources</a
> >
</li> </li>
<li @if (
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription && user?.subscription?.type === 'Basic'
" ) {
class="list-inline-item" <li class="list-inline-item">
>
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
i18n i18n
@ -98,6 +98,7 @@
>Pricing</a >Pricing</a
> >
</li> </li>
}
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
@ -111,12 +112,16 @@
>About</a >About</a
> >
</li> </li>
<li *ngIf="hasPermissionToAccessAssistant" class="list-inline-item"> @if (hasPermissionToAccessAssistant) {
<li class="list-inline-item">
<button <button
#assistantTrigger="matMenuTrigger" #assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2" class="h-100 no-min-width px-2"
mat-button mat-button
matBadge="✓"
matBadgeSize="small"
[mat-menu-trigger-for]="assistantMenu" [mat-menu-trigger-for]="assistantMenu"
[matBadgeHidden]="!hasFilters"
[matMenuTriggerRestoreFocus]="false" [matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()" (menuOpened)="onOpenAssistant()"
> >
@ -142,6 +147,7 @@
/> />
</mat-menu> </mat-menu>
</li> </li>
}
<li class="list-inline-item"> <li class="list-inline-item">
<button <button
class="no-min-width px-1" class="no-min-width px-1"
@ -162,40 +168,31 @@
/> />
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container @if (
*ngIf=" hasPermissionForSubscription && user?.subscription?.type === 'Basic'
hasPermissionForSubscription && ) {
user?.subscription?.type === 'Basic'
"
>
<a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing" <a class="d-flex" mat-menu-item [routerLink]="routerLinkPricing"
><span class="align-items-center d-flex" ><span class="align-items-center d-flex"
><span ><span>
><ng-container @if (user.subscription.offer === 'default') {
*ngIf="user.subscription.offer === 'default'" <ng-container i18n>Upgrade Plan</ng-container>
i18n } @else if (
>Upgrade Plan</ng-container
>
<ng-container
*ngIf="
user.subscription.offer === 'renewal' || user.subscription.offer === 'renewal' ||
user.subscription.offer === 'renewal-early-bird' user.subscription.offer === 'renewal-early-bird'
" ) {
i18n <ng-container i18n>Renew Plan</ng-container>
>Renew Plan</ng-container }
></span </span>
>
<gf-premium-indicator <gf-premium-indicator
class="d-inline-block ml-1" class="d-inline-block ml-1"
[enableLink]="false" /></span [enableLink]="false" /></span
></a> ></a>
<hr class="m-0" /> <hr class="m-0" />
</ng-container> }
<ng-container *ngIf="user?.access?.length > 0"> @if (user?.access?.length > 0) {
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2" class="mr-2"
[name]=" [name]="
impersonationId impersonationId
@ -206,11 +203,8 @@
<span i18n>Me</span> <span i18n>Me</span>
</span> </span>
</button> </button>
<button @for (accessItem of user?.access; track accessItem) {
*ngFor="let accessItem of user?.access" <button mat-menu-item (click)="impersonateAccount(accessItem.id)">
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon
class="mr-2" class="mr-2"
@ -221,12 +215,16 @@
: 'radio-button-off-outline' : 'radio-button-off-outline'
" "
/> />
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span> @if (accessItem.alias) {
<span *ngIf="!accessItem.alias" i18n>User</span> <span>{{ accessItem.alias }}</span>
} @else {
<span i18n>User</span>
}
</span> </span>
</button> </button>
}
<hr class="m-0" /> <hr class="m-0" />
</ng-container> }
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
@ -263,8 +261,8 @@
[routerLink]="['/account']" [routerLink]="['/account']"
>My Ghostfolio</a >My Ghostfolio</a
> >
@if (hasPermissionToAccessAdminControl) {
<a <a
*ngIf="hasPermissionToAccessAdminControl"
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
@ -272,6 +270,7 @@
[routerLink]="['/admin']" [routerLink]="['/admin']"
>Admin Control</a >Admin Control</a
> >
}
<hr class="m-0" /> <hr class="m-0" />
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
@ -283,11 +282,10 @@
[routerLink]="routerLinkResources" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
@if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
) {
<a <a
*ngIf="
hasPermissionForSubscription &&
user?.subscription?.type === 'Basic'
"
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
@ -295,6 +293,7 @@
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
}
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
@ -308,8 +307,8 @@
</mat-menu> </mat-menu>
</li> </li>
</ul> </ul>
</ng-container> }
<ng-container *ngIf="user === null"> @if (user === null) {
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }"> <div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a <a
class="align-items-center justify-content-start rounded-0" class="align-items-center justify-content-start rounded-0"
@ -352,7 +351,8 @@
>About</a >About</a
> >
</li> </li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item"> @if (hasPermissionForSubscription) {
<li class="list-inline-item">
<a <a
class="d-sm-block" class="d-sm-block"
i18n i18n
@ -365,10 +365,9 @@
>Pricing</a >Pricing</a
> >
</li> </li>
<li }
*ngIf="hasPermissionToAccessFearAndGreedIndex" @if (hasPermissionToAccessFearAndGreedIndex) {
class="list-inline-item" <li class="list-inline-item">
>
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
i18n i18n
@ -381,6 +380,7 @@
>Markets</a >Markets</a
> >
</li> </li>
}
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block no-min-width p-1" class="d-none d-sm-block no-min-width p-1"
@ -394,10 +394,8 @@
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>
</li> </li>
<li @if (currentRoute !== 'register' && hasPermissionToCreateUser) {
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser" <li class="list-inline-item ml-1">
class="list-inline-item ml-1"
>
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
@ -406,6 +404,7 @@
><ng-container i18n>Get started</ng-container> ><ng-container i18n>Get started</ng-container>
</a> </a>
</li> </li>
}
</ul> </ul>
</ng-container> }
</mat-toolbar> </mat-toolbar>

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

Loading…
Cancel
Save