Browse Source

Merge pull request #179 from dandevaud/mr/Upstream-2025-06-16

Mr/upstream 2025 06 16
pull/5027/head
dandevaud 2 weeks ago
committed by GitHub
parent
commit
9186d60c12
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/build-code.yml
  2. 2
      .gitignore
  3. 2
      .nvmrc
  4. 204
      CHANGELOG.md
  5. 2
      DEVELOPMENT.md
  6. 9
      Dockerfile
  7. 2
      apps/api/src/app/access/access.controller.ts
  8. 8
      apps/api/src/app/account/account.controller.ts
  9. 9
      apps/api/src/app/admin/admin.controller.ts
  10. 2
      apps/api/src/app/admin/admin.module.ts
  11. 11
      apps/api/src/app/admin/admin.service.ts
  12. 23
      apps/api/src/app/app.module.ts
  13. 2
      apps/api/src/app/auth/api-key.strategy.ts
  14. 16
      apps/api/src/app/auth/jwt.strategy.ts
  15. 2
      apps/api/src/app/auth/web-auth.service.ts
  16. 10
      apps/api/src/app/endpoints/ai/ai.controller.ts
  17. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  18. 2
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  19. 173
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  20. 4
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  21. 2
      apps/api/src/app/endpoints/public/public.module.ts
  22. 15
      apps/api/src/app/export/export.controller.ts
  23. 9
      apps/api/src/app/export/export.module.ts
  24. 2
      apps/api/src/app/import/create-account-with-balances.dto.ts
  25. 2
      apps/api/src/app/import/import.controller.ts
  26. 32
      apps/api/src/app/import/import.service.ts
  27. 9
      apps/api/src/app/order/order.controller.ts
  28. 3
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  29. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  30. 304
      apps/api/src/app/portfolio/portfolio.service.ts
  31. 64
      apps/api/src/app/redis-cache/redis-cache.service.ts
  32. 4
      apps/api/src/app/subscription/subscription.service.ts
  33. 4
      apps/api/src/app/tag/tag.service.ts
  34. 2
      apps/api/src/app/user/user.module.ts
  35. 33
      apps/api/src/app/user/user.service.ts
  36. 736
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  37. 30
      apps/api/src/assets/sitemap.xml
  38. 11
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  39. 15
      apps/api/src/main.ts
  40. 158
      apps/api/src/middlewares/html-template.middleware.ts
  41. 15
      apps/api/src/models/rule.ts
  42. 7
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  43. 7
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  44. 7
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  45. 7
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  46. 7
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  47. 7
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  48. 7
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  49. 7
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  50. 24
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  51. 36
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  52. 7
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  53. 7
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  54. 7
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  55. 7
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  56. 7
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  57. 23
      apps/api/src/services/cron/cron.module.ts
  58. 15
      apps/api/src/services/cron/cron.service.ts
  59. 21
      apps/api/src/services/data-provider/data-provider.service.ts
  60. 15
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  61. 25
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  62. 13
      apps/api/src/services/demo/demo.module.ts
  63. 59
      apps/api/src/services/demo/demo.service.ts
  64. 9
      apps/api/src/services/i18n/i18n.module.ts
  65. 15
      apps/api/src/services/i18n/i18n.service.ts
  66. 2
      apps/api/src/services/impersonation/impersonation.service.ts
  67. 6
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  68. 6
      apps/api/src/services/tag/tag.service.ts
  69. 1
      apps/client-e2e/project.json
  70. 61
      apps/client/project.json
  71. 69
      apps/client/src/app/app-routing.module.ts
  72. 6
      apps/client/src/app/app.component.html
  73. 6
      apps/client/src/app/app.component.scss
  74. 113
      apps/client/src/app/app.component.ts
  75. 6
      apps/client/src/app/components/access-table/access-table.component.ts
  76. 19
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  77. 8
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  78. 4
      apps/client/src/app/components/accounts-table/accounts-table.module.ts
  79. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  80. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  81. 25
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  82. 21
      apps/client/src/app/components/admin-overview/admin-overview.html
  83. 2
      apps/client/src/app/components/admin-overview/admin-overview.module.ts
  84. 2
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  85. 4
      apps/client/src/app/components/admin-platform/admin-platform.module.ts
  86. 6
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html
  87. 4
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts
  88. 15
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  89. 13
      apps/client/src/app/components/admin-settings/admin-settings.component.scss
  90. 63
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  91. 6
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  92. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  93. 3
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  94. 49
      apps/client/src/app/components/header/header.component.html
  95. 12
      apps/client/src/app/components/header/header.component.scss
  96. 33
      apps/client/src/app/components/header/header.component.ts
  97. 40
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  98. 8
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  99. 3
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  100. 2
      apps/client/src/app/components/home-holdings/home-holdings.html

2
.github/workflows/build-code.yml

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

2
.gitignore

@ -25,8 +25,10 @@ npm-debug.log
# misc # misc
/.angular/cache /.angular/cache
.cursor/rules/nx-rules.mdc
.env .env
.env.prod .env.prod
.github/instructions/nx.instructions.md
.nx/cache .nx/cache
.nx/workspace-data .nx/workspace-data
/.sass-cache /.sass-cache

2
.nvmrc

@ -1 +1 @@
v20 v22

204
CHANGELOG.md

@ -7,6 +7,185 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changelog
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for Portuguese (`pt`)
## 2.171.0 - 2025-06-15
### Added
- Added the current holdings as default options of the symbol search in the create or update activity dialog
### Changed
- Improved the style of the assistant
- Reused the value component in the data providers management of the admin control panel
- Set the market state of exchange rate symbols to `open` in the _Financial Modeling Prep_ service
- Restructured the content of the pricing page
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Migrated the `HtmlTemplateMiddleware` to use `@Injectable()`
- Renamed `User` to `user` in the database schema
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Español (`es`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Portuguese (`pt`)
- Improved the language localization for Turkish (`tr`)
- Upgraded the _Stripe_ dependencies
### Fixed
- Fixed a date offset issue with account balances
- Fixed missing `/.well-known/assetlinks.json` for TWA
## 2.170.0 - 2025-06-11
### Added
- Included quick links in the search results of the assistant
- Added a skeleton loader to the changelog page
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Renamed `ApiKey` to `apiKeys` in the `User` database schema
- Improved the language localization for French (`fr`)
- Improved the language localization for Portuguese (`pt`)
- Upgraded `@keyv/redis` from version `4.3.4` to `4.4.0`
- Upgraded `prisma` from version `6.8.2` to `6.9.0`
- Upgraded `zone.js` from version `0.15.0` to `0.15.1`
### Fixed
- Restricted the date range change permission in the _Zen Mode_
## 2.169.0 - 2025-06-08
### Changed
- Renamed the asset profile icon component to entity logo component and moved to `@ghostfolio/ui`
- Renamed `Account` to `accounts` in the `User` database schema
- Improved the cache verification in the health check endpoint (experimental)
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for French (`fr`)
- Improved the language localization for Polish (`pl`)
### Fixed
- Handled an exception in the get keys function of the _Redis_ cache service
- Fixed missing `/.well-known/assetlinks.json` for TWA
## 2.168.0 - 2025-06-07
### Added
- Added a background gradient to the sidebar navigation
### Changed
- Migrated the `i18n` service to use `@Injectable()`
- Improved the language localization for German (`de`)
- Upgraded `nestjs` from version `11.1.0` to `11.1.3`
## 2.167.0 - 2025-06-07
### Added
- Added support for column sorting to the markets overview
- Added support for column sorting to the watchlist
- Set up the language localization for the static portfolio analysis rule: _Emergency Fund_ (Setup)
- Set up the language localization for the static portfolio analysis rule: _Fees_ (Fee Ratio)
### Changed
- Extended the symbol search component by default options
- Renamed `Tag` to `tags` in the `User` database schema
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Turkish (`tr`)
- Upgraded `ng-extract-i18n-merge` from version `2.15.0` to `2.15.1`
- Upgraded `Nx` from version `20.8.1` to `21.1.2`
### Fixed
- Fixed an issue where the import button was not correctly enabled in the import activities dialog
- Fixed an issue with empty account balances in the import activities dialog
- Fixed an issue in the annualized performance calculation
## 2.166.0 - 2025-06-05
### Added
- Added support to create custom tags in the create or update activity dialog (experimental)
### Changed
- Improved the style of the card components
- Improved the style of the system message
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Turkish (`tr`)
- Improved the language localization for Ukrainian (`uk`)
- Upgraded the _Stripe_ dependencies
- Upgraded `ngx-stripe` from version `19.0.0` to `19.7.0`
### Fixed
- Respected the filter by holding when deleting activities on the portfolio activities page
- Respected the filter by holding when exporting activities on the portfolio activities page
- Fixed an exception with currencies in the historical market data editor of the admin control panel
## 2.165.0 - 2025-05-31
### Added
- Extended the content of the _General_ section by the performance calculation method on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the _Live Demo_ setup by syncing activities based on tags
- Renamed `orders` to `activities` in the `Tag` database schema
- Modularized the cron service
- Refreshed the cryptocurrencies list
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Spanish (`es`)
- Upgraded `big.js` from version `6.2.2` to `7.0.1`
- Upgraded `ng-extract-i18n-merge` from version `2.14.3` to `2.15.0`
### Fixed
- Changed the investment value to take the currency effects into account in the holding detail dialog
## 2.164.0 - 2025-05-28
### Changed
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Spanish (`es`)
- Upgraded `Node.js` from version `20` to `22` (`Dockerfile`)
- Upgraded `yahoo-finance2` from version `3.3.4` to `3.3.5`
## 2.163.0 - 2025-05-26
### Changed
- Improved the language localization for Italian (`it`)
- Improved the language localization for Turkish (`tr`)
- Upgraded `yahoo-finance2` from version `3.3.3` to `3.3.4`
## 2.162.1 - 2025-05-24
### Added ### Added
- Added a hint about delayed market data to the markets overview - Added a hint about delayed market data to the markets overview
@ -14,6 +193,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Increased the robustness of the search in the _Yahoo Finance_ service by catching schema validation errors
- Improved the symbol lookup results by removing the currency from the name of cryptocurrencies (experimental)
- Harmonized the data providers management style of the admin control panel - Harmonized the data providers management style of the admin control panel
- Extended the data providers management of the admin control panel by the asset profile count - Extended the data providers management of the admin control panel by the asset profile count
- Restricted the permissions of the demo user - Restricted the permissions of the demo user
@ -27,15 +208,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the language localization for French (`fr`) - Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`) - Improved the language localization for Italian (`it`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Portuguese (`pt`)
- Improved the language localization for Spanish (`es`)
- Upgraded `countup.js` from version `2.8.0` to `2.8.2` - Upgraded `countup.js` from version `2.8.0` to `2.8.2`
- Upgraded `nestjs` from version `10.4.15` to `11.0.12` - Upgraded `nestjs` from version `10.4.15` to `11.0.12`
- Upgraded `prisma` from version `6.7.0` to `6.8.2`
- Upgraded `twitter-api-v2` from version `1.14.2` to `1.23.0` - Upgraded `twitter-api-v2` from version `1.14.2` to `1.23.0`
- Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.2` - Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.3`
### Fixed ### Fixed
- Displayed the button to fetch the current market price only if the activity is not in a custom currency - Displayed the button to fetch the current market price only if the activity is not in a custom currency
- Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard` - Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard`
- Improved the text alignment of the allocations by ETF holding on the allocations page (experimental)
## 2.161.0 - 2025-05-06 ## 2.161.0 - 2025-05-06
@ -81,7 +267,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the language localization for Français (`fr`) - Improved the language localization for French (`fr`)
- Upgraded `bootstrap` from version `4.6.0` to `4.6.2` - Upgraded `bootstrap` from version `4.6.0` to `4.6.2`
### Fixed ### Fixed
@ -122,7 +308,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the error message of the currency code validation - Improved the error message of the currency code validation
- Tightened the currency code validation by requiring uppercase letters - Tightened the currency code validation by requiring uppercase letters
- Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel - Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel
- Improved the language localization for Français (`fr`) - Improved the language localization for French (`fr`)
- Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0` - Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0`
- Upgraded `Nx` from version `20.8.0` to `20.8.1` - Upgraded `Nx` from version `20.8.0` to `20.8.1`
@ -222,7 +408,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the check for duplicates in the preview step of the activities import (allow different comments) - Improved the check for duplicates in the preview step of the activities import (allow different comments)
- Improved the language localization for Français (`fr`) - Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`) - Improved the language localization for Polish (`pl`)
- Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3` - Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3`
@ -3762,7 +3948,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the language localization for Français (`fr`) - Added the language localization for French (`fr`)
- Extended the landing page by a global heat map of subscribers - Extended the landing page by a global heat map of subscribers
- Added support for the thousand separator in the global heat map component - Added support for the thousand separator in the global heat map component
@ -3791,7 +3977,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for the dividend timeline grouped by year - Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year - Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`) - Set up the language localization for French (`fr`)
### Changed ### Changed
@ -3900,7 +4086,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the value redaction interceptor (including `comment`) - Improved the value redaction interceptor (including `comment`)
- Improved the language localization for Español (`es`) - Improved the language localization for Spanish (`es`)
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12` - Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
- Upgraded `prisma` from version `4.6.1` to `4.7.1` - Upgraded `prisma` from version `4.6.1` to `4.7.1`
@ -4129,7 +4315,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the usage of the value component in the admin control panel - Improved the usage of the value component in the admin control panel
- Improved the language localization for Español (`es`) - Improved the language localization for Spanish (`es`)
### Fixed ### Fixed
@ -4151,7 +4337,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Set up the language localization for Español (`es`) - Set up the language localization for Spanish (`es`)
- Added support for sectors in mutual funds - Added support for sectors in mutual funds
## 1.198.0 - 25.09.2022 ## 1.198.0 - 25.09.2022

2
DEVELOPMENT.md

@ -5,7 +5,7 @@
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 20+) - [Node.js](https://nodejs.org/en/download) (version 22+)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)

9
Dockerfile

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:20-slim AS builder FROM --platform=$BUILDPLATFORM node:22-slim AS builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -33,24 +33,25 @@ COPY ./nx.json nx.json
COPY ./replace.build.mjs replace.build.mjs COPY ./replace.build.mjs replace.build.mjs
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
ENV NX_DAEMON=false
RUN npm run build:production RUN npm run build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions # package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN npm install RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api COPY package.json /ghostfolio/dist/apps/api
RUN npm run database:generate-typings RUN npm run database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:20-slim FROM node:22-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production ENV NODE_ENV=production

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

@ -89,7 +89,7 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } catch {
throw new HttpException( throw new HttpException(

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

@ -153,7 +153,7 @@ export class AccountController {
{ {
...data, ...data,
Platform: { connect: { id: platformId } }, Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -163,7 +163,7 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -251,7 +251,7 @@ export class AccountController {
data: { data: {
...data, ...data,
Platform: { connect: { id: platformId } }, Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {
@ -273,7 +273,7 @@ export class AccountController {
Platform: originalAccount.platformId Platform: originalAccount.platformId
? { disconnect: true } ? { disconnect: true }
: undefined, : undefined,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {

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

@ -3,6 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { import {
@ -55,6 +56,7 @@ export class AdminController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -66,6 +68,13 @@ export class AdminController {
return this.adminService.get({ user: this.request.user }); return this.adminService.get({ user: this.request.user });
} }
@Get('demo-user/sync')
@HasPermission(permissions.syncDemoUserAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
return this.demoService.syncDemoUserAccount();
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -4,6 +4,7 @@ import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DemoModule } from '@ghostfolio/api/services/demo/demo.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';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -25,6 +26,7 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

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

@ -135,7 +135,10 @@ export class AdminService {
} }
public async get({ user }: { user: UserWithSettings }): Promise<AdminData> { public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
const dataSources = await this.dataProviderService.getDataSources({ user }); const dataSources = await this.dataProviderService.getDataSources({
user,
includeGhostfolio: true
});
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
@ -676,7 +679,7 @@ export class AdminService {
select: { select: {
activities: { activities: {
where: { where: {
User: { user: {
subscriptions: { subscriptions: {
some: { some: {
expiresAt: { expiresAt: {
@ -827,7 +830,7 @@ export class AdminService {
where, where,
select: { select: {
_count: { _count: {
select: { Account: true, activities: true } select: { accounts: true, activities: true }
}, },
Analytics: { Analytics: {
select: { select: {
@ -874,7 +877,7 @@ export class AdminService {
id, id,
role, role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.accounts || 0,
activityCount: _count.activities || 0, activityCount: _count.activities || 0,
country: Analytics?.country, country: Analytics?.country,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0, dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,

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

@ -1,20 +1,21 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service'; import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -78,6 +79,7 @@ import { UserModule } from './user/user.module';
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
CronModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
@ -101,7 +103,7 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'], exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {
@ -124,14 +126,21 @@ import { UserModule } from './user/user.module';
} }
} }
}), }),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'),
serveRoot: '/.well-known'
}),
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagsModule, TagsModule,
TwitterBotModule,
UserModule, UserModule,
WatchlistModule WatchlistModule
], ],
providers: [CronService] providers: [I18nService]
}) })
export class AppModule {} export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
consumer.apply(HtmlTemplateMiddleware).forRoutes('*');
}
}

2
apps/api/src/app/auth/api-key.strategy.ts

@ -36,7 +36,7 @@ export class ApiKeyStrategy extends PassportStrategy(
} }
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } }, create: { user: { connect: { id: user.id } } },
update: { update: {
activityCount: { increment: 1 }, activityCount: { increment: 1 },
lastRequestAt: new Date() lastRequestAt: new Date()

16
apps/api/src/app/auth/jwt.strategy.ts

@ -1,7 +1,11 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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 { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
HEADER_KEY_TIMEZONE
} from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions'; import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
@ -42,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } }, create: { country, user: { connect: { id: user.id } } },
update: { update: {
country, country,
activityCount: { increment: 1 }, activityCount: { increment: 1 },
@ -52,6 +56,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}); });
} }
if (!user.Settings.settings.baseCurrency) {
user.Settings.settings.baseCurrency = DEFAULT_CURRENCY;
}
if (!user.Settings.settings.language) {
user.Settings.settings.language = DEFAULT_LANGUAGE_CODE;
}
return user; return user;
} else { } else {
throw new HttpException( throw new HttpException(

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

@ -131,7 +131,7 @@ export class WebAuthService {
counter, counter,
credentialId: Buffer.from(credentialId), credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey), credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } user: { connect: { id: user.id } }
}); });
} }

10
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,10 +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 { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE
} from '@ghostfolio/common/config';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
@ -53,10 +49,8 @@ export class AiController {
filters, filters,
mode, mode,
impersonationId: undefined, impersonationId: undefined,
languageCode: languageCode: this.request.user.Settings.settings.language,
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE, userCurrency: this.request.user.Settings.settings.baseCurrency,
userCurrency:
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: this.request.user.id userId: this.request.user.id
}); });

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

@ -12,6 +12,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -32,6 +33,7 @@ import { AiService } from './ai.service';
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

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

@ -15,6 +15,7 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -35,6 +36,7 @@ import { BenchmarksService } from './benchmarks.service';
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

173
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -74,48 +74,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividendsV1(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('dividends/:symbol') @Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -156,48 +114,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistoricalV1(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol') @Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -238,47 +154,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbolV1(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup') @Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -320,44 +195,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotesV1(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes') @Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -394,16 +231,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status') @Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)

4
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -85,7 +85,7 @@ export class MarketDataController {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -103,7 +103,7 @@ export class MarketDataController {
); );
const canUpsertOwnAssetProfile = const canUpsertOwnAssetProfile =
assetProfile.userId === this.request.user.id && assetProfile?.userId === this.request.user.id &&
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.createMarketDataOfOwnAssetProfile permissions.createMarketDataOfOwnAssetProfile

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

@ -12,6 +12,7 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { 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';
@ -29,6 +30,7 @@ import { PublicController } from './public.controller';
BenchmarkModule, BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

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

@ -1,9 +1,17 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Inject,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -19,16 +27,21 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });

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

@ -1,5 +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 { 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 { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -9,8 +10,14 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [AccountModule, ApiModule, OrderModule, TagModule],
controllers: [ExportController], controllers: [ExportController],
imports: [
AccountModule,
ApiModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],
providers: [ExportService] providers: [ExportService]
}) })
export class ExportModule {} export class ExportModule {}

2
apps/api/src/app/import/create-account-with-balances.dto.ts

@ -6,5 +6,5 @@ import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto { export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
balances?: AccountBalance; balances?: AccountBalance[];
} }

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

@ -71,7 +71,7 @@ export class ImportController {
const activities = await this.importService.import({ const activities = await this.importService.import({
isDryRun, isDryRun,
maxActivitiesToImport, maxActivitiesToImport,
accountsDto: importData.accounts ?? [], accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
user: this.request.user user: this.request.user
}); });

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

@ -28,9 +28,11 @@ import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ImportDataDto } from './import-data.dto';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
@ -138,14 +140,14 @@ export class ImportService {
} }
public async import({ public async import({
accountsDto, accountsWithBalancesDto,
activitiesDto, activitiesDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
user user
}: { }: {
accountsDto: Partial<CreateAccountDto>[]; accountsWithBalancesDto: ImportDataDto['accounts'];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: ImportDataDto['activities'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings; user: UserWithSettings;
@ -153,12 +155,12 @@ export class ImportService {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency; const userCurrency = user.Settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) { if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where: { where: {
id: { id: {
in: accountsDto.map(({ id }) => { in: accountsWithBalancesDto.map(({ id }) => {
return id; return id;
}) })
} }
@ -167,14 +169,19 @@ export class ImportService {
this.platformService.getPlatforms() this.platformService.getPlatforms()
]); ]);
for (const account of accountsDto) { for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID // Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find((existingAccount) => { const accountWithSameId = existingAccounts.find((existingAccount) => {
return existingAccount.id === account.id; return existingAccount.id === accountWithBalances.id;
}); });
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId = account.platformId;
@ -187,7 +194,10 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
User: { connect: { id: user.id } } balances: {
create: accountWithBalances.balances ?? []
},
user: { connect: { id: user.id } }
}; };
if ( if (
@ -251,7 +261,7 @@ export class ImportService {
); );
if (isDryRun) { if (isDryRun) {
accountsDto.forEach(({ id, name }) => { accountsWithBalancesDto.forEach(({ id, name }) => {
accounts.push({ id, name }); accounts.push({ id, name });
}); });
} }
@ -386,7 +396,7 @@ export class ImportService {
} }
}, },
updateAccountBalance: false, updateAccountBalance: false,
User: { connect: { id: user.id } }, user: { connect: { id: user.id } },
userId: user.id userId: user.id
}); });

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

@ -53,14 +53,19 @@ export class OrderController {
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders( public async deleteOrders(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<number> { ): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -209,7 +214,7 @@ export class OrderController {
} }
} }
}, },
User: { connect: { id: this.request.user.id } }, user: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
@ -282,7 +287,7 @@ export class OrderController {
name: data.symbol name: data.symbol
} }
}, },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id id

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

@ -35,6 +35,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types'; import { GroupBy } from '@ghostfolio/common/types';
import { DateRangeTypes } from '@ghostfolio/common/types/date-range.type';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -1452,7 +1453,7 @@ export abstract class PortfolioCalculator {
chartDateMap[format(endDate, DATE_FORMAT)] = true; chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present // Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { for (const dateRange of DateRangeTypes) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } = const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange); getIntervalFromDateRange(dateRange);

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

@ -13,6 +13,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { 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';
@ -39,6 +40,7 @@ import { RulesService } from './rules.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

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

@ -24,6 +24,7 @@ import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/ru
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -32,7 +33,7 @@ import {
} from '@ghostfolio/common/calculation-helper'; } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, TAG_ID_EMERGENCY_FUND,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
@ -106,6 +107,7 @@ export class PortfolioService {
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
@ -554,7 +556,7 @@ export class PortfolioService {
} }
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = await this.getCashPositions({ const cashPositions = this.getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
@ -576,10 +578,10 @@ export class PortfolioService {
if ( if (
filters?.length === 1 && filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID && filters[0].id === TAG_ID_EMERGENCY_FUND &&
filters[0].type === 'TAG' filters[0].type === 'TAG'
) { ) {
const emergencyFundCashPositions = await this.getCashPositions({ const emergencyFundCashPositions = this.getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
@ -677,7 +679,7 @@ export class PortfolioService {
grossPerformancePercentWithCurrencyEffect: undefined, grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined, grossPerformanceWithCurrencyEffect: undefined,
historicalData: [], historicalData: [],
investment: undefined, investmentInBaseCurrencyWithCurrencyEffect: undefined,
marketPrice: undefined, marketPrice: undefined,
marketPriceMax: undefined, marketPriceMax: undefined,
marketPriceMin: undefined, marketPriceMin: undefined,
@ -878,7 +880,8 @@ export class PortfolioService {
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
position.grossPerformanceWithCurrencyEffect?.toNumber(), position.grossPerformanceWithCurrencyEffect?.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: position.investmentWithCurrencyEffect?.toNumber(), investmentInBaseCurrencyWithCurrencyEffect:
position.investmentWithCurrencyEffect?.toNumber(),
netPerformance: position.netPerformance?.toNumber(), netPerformance: position.netPerformance?.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(),
netPerformancePercentWithCurrencyEffect: netPerformancePercentWithCurrencyEffect:
@ -978,7 +981,7 @@ export class PortfolioService {
grossPerformancePercentWithCurrencyEffect: undefined, grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined, grossPerformanceWithCurrencyEffect: undefined,
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: 0, investmentInBaseCurrencyWithCurrencyEffect: 0,
netPerformance: undefined, netPerformance: undefined,
netPerformancePercent: undefined, netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined, netPerformancePercentWithCurrencyEffect: undefined,
@ -1333,6 +1336,8 @@ export class PortfolioService {
[ [
new EmergencyFundSetup( new EmergencyFundSetup(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
this.getTotalEmergencyFund({ this.getTotalEmergencyFund({
userSettings, userSettings,
emergencyFundHoldingsValueInBaseCurrency: emergencyFundHoldingsValueInBaseCurrency:
@ -1346,6 +1351,8 @@ export class PortfolioService {
[ [
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds, summary.committedFunds,
summary.fees summary.fees
) )
@ -1408,8 +1415,146 @@ export class PortfolioService {
await this.orderService.assignTags({ dataSource, symbol, tags, userId }); await this.orderService.assignTags({ dataSource, symbol, tags, userId });
} }
@LogPerformance private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
private async getCashPositions({ markets: PortfolioDetails['markets'];
marketsAdvanced: PortfolioDetails['marketsAdvanced'];
} {
const markets: PortfolioDetails['markets'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
developedMarkets: {
id: 'developedMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
const marketsAdvanced: PortfolioDetails['marketsAdvanced'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
asiaPacific: {
id: 'asiaPacific',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
europe: {
id: 'europe',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
japan: {
id: 'japan',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
northAmerica: {
id: 'northAmerica',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency +=
position.markets.emergingMarkets * value;
markets.otherMarkets.valueInBaseCurrency +=
position.markets.otherMarkets * value;
marketsAdvanced.asiaPacific.valueInBaseCurrency +=
position.marketsAdvanced.asiaPacific * value;
marketsAdvanced.emergingMarkets.valueInBaseCurrency +=
position.marketsAdvanced.emergingMarkets * value;
marketsAdvanced.europe.valueInBaseCurrency +=
position.marketsAdvanced.europe * value;
marketsAdvanced.japan.valueInBaseCurrency +=
position.marketsAdvanced.japan * value;
marketsAdvanced.northAmerica.valueInBaseCurrency +=
position.marketsAdvanced.northAmerica * value;
marketsAdvanced.otherMarkets.valueInBaseCurrency +=
position.marketsAdvanced.otherMarkets * value;
} else {
markets[UNKNOWN_KEY].valueInBaseCurrency += value;
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
}
}
}
const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
markets.developedMarkets.valueInPercentage =
markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.emergingMarkets.valueInPercentage =
markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.otherMarkets.valueInPercentage =
markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets[UNKNOWN_KEY].valueInPercentage =
markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency;
const marketsAdvancedTotal =
marketsAdvanced.asiaPacific.valueInBaseCurrency +
marketsAdvanced.emergingMarkets.valueInBaseCurrency +
marketsAdvanced.europe.valueInBaseCurrency +
marketsAdvanced.japan.valueInBaseCurrency +
marketsAdvanced.northAmerica.valueInBaseCurrency +
marketsAdvanced.otherMarkets.valueInBaseCurrency +
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency;
marketsAdvanced.asiaPacific.valueInPercentage =
marketsAdvanced.asiaPacific.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.emergingMarkets.valueInPercentage =
marketsAdvanced.emergingMarkets.valueInBaseCurrency /
marketsAdvancedTotal;
marketsAdvanced.europe.valueInPercentage =
marketsAdvanced.europe.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.japan.valueInPercentage =
marketsAdvanced.japan.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.northAmerica.valueInPercentage =
marketsAdvanced.northAmerica.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.otherMarkets.valueInPercentage =
marketsAdvanced.otherMarkets.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced[UNKNOWN_KEY].valueInPercentage =
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency / marketsAdvancedTotal;
return { markets, marketsAdvanced };
}
private getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
value value
@ -1470,7 +1615,7 @@ export class PortfolioService {
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return ( return (
tags?.some(({ id }) => { tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID; return id === TAG_ID_EMERGENCY_FUND;
}) ?? false }) ?? false
); );
}); });
@ -2089,143 +2234,4 @@ export class PortfolioService {
): PerformanceCalculationType { ): PerformanceCalculationType {
return aUser?.Settings?.settings.performanceCalculationType; return aUser?.Settings?.settings.performanceCalculationType;
} }
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
markets: PortfolioDetails['markets'];
marketsAdvanced: PortfolioDetails['marketsAdvanced'];
} {
const markets: PortfolioDetails['markets'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
developedMarkets: {
id: 'developedMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
const marketsAdvanced: PortfolioDetails['marketsAdvanced'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
asiaPacific: {
id: 'asiaPacific',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
europe: {
id: 'europe',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
japan: {
id: 'japan',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
northAmerica: {
id: 'northAmerica',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency +=
position.markets.emergingMarkets * value;
markets.otherMarkets.valueInBaseCurrency +=
position.markets.otherMarkets * value;
marketsAdvanced.asiaPacific.valueInBaseCurrency +=
position.marketsAdvanced.asiaPacific * value;
marketsAdvanced.emergingMarkets.valueInBaseCurrency +=
position.marketsAdvanced.emergingMarkets * value;
marketsAdvanced.europe.valueInBaseCurrency +=
position.marketsAdvanced.europe * value;
marketsAdvanced.japan.valueInBaseCurrency +=
position.marketsAdvanced.japan * value;
marketsAdvanced.northAmerica.valueInBaseCurrency +=
position.marketsAdvanced.northAmerica * value;
marketsAdvanced.otherMarkets.valueInBaseCurrency +=
position.marketsAdvanced.otherMarkets * value;
} else {
markets[UNKNOWN_KEY].valueInBaseCurrency += value;
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
}
}
}
const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
markets.developedMarkets.valueInPercentage =
markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.emergingMarkets.valueInPercentage =
markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.otherMarkets.valueInPercentage =
markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets[UNKNOWN_KEY].valueInPercentage =
markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency;
const marketsAdvancedTotal =
marketsAdvanced.asiaPacific.valueInBaseCurrency +
marketsAdvanced.emergingMarkets.valueInBaseCurrency +
marketsAdvanced.europe.valueInBaseCurrency +
marketsAdvanced.japan.valueInBaseCurrency +
marketsAdvanced.northAmerica.valueInBaseCurrency +
marketsAdvanced.otherMarkets.valueInBaseCurrency +
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency;
marketsAdvanced.asiaPacific.valueInPercentage =
marketsAdvanced.asiaPacific.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.emergingMarkets.valueInPercentage =
marketsAdvanced.emergingMarkets.valueInBaseCurrency /
marketsAdvancedTotal;
marketsAdvanced.europe.valueInPercentage =
marketsAdvanced.europe.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.japan.valueInPercentage =
marketsAdvanced.japan.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.northAmerica.valueInPercentage =
marketsAdvanced.northAmerica.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.otherMarkets.valueInPercentage =
marketsAdvanced.otherMarkets.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced[UNKNOWN_KEY].valueInPercentage =
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency / marketsAdvancedTotal;
return { markets, marketsAdvanced };
}
} }

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

@ -5,17 +5,28 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
private client: Keyv;
public constructor( public constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache, @Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
const client = cache.stores[0]; this.client = cache.stores[0];
this.client.deserialize = (value) => {
try {
return JSON.parse(value);
} catch {}
client.on('error', (error) => { return value;
};
this.client.on('error', (error) => {
Logger.error(error, 'RedisCacheService'); Logger.error(error, 'RedisCacheService');
}); });
} }
@ -28,28 +39,13 @@ export class RedisCacheService {
const keys: string[] = []; const keys: string[] = [];
const prefix = aPrefix; const prefix = aPrefix;
this.cache.stores[0].deserialize = (value) => { try {
try { for await (const [key] of this.client.iterator({})) {
return JSON.parse(value); if ((prefix && key.startsWith(prefix)) || !prefix) {
} catch (error: any) { keys.push(key);
if (error instanceof SyntaxError) {
Logger.debug(
`Failed to parse json, returning the value as String: ${value}`,
'RedisCacheService'
);
return value;
} else {
throw error;
} }
} }
}; } catch {}
for await (const [key] of this.cache.stores[0].iterator({})) {
if ((prefix && key.startsWith(prefix)) || !prefix) {
keys.push(key);
}
}
return keys; return keys;
} }
@ -79,20 +75,36 @@ export class RedisCacheService {
} }
public async isHealthy() { public async isHealthy() {
const testKey = '__health_check__';
const testValue = Date.now().toString();
try { try {
const isHealthy = await Promise.race([ await Promise.race([
this.getKeys(), (async () => {
await this.set(testKey, testValue, ms('1 second'));
const result = await this.get(testKey);
if (result !== testValue) {
throw new Error('Redis health check failed: value mismatch');
}
})(),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check timeout')), () => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds') ms('2 seconds')
) )
) )
]); ]);
return isHealthy === 'PONG'; return true;
} catch (error) { } catch (error) {
Logger.error(error?.message, 'RedisCacheService');
return false; return false;
} finally {
try {
await this.remove(testKey);
} catch {}
} }
} }

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

@ -32,7 +32,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: '2024-09-30.acacia' apiVersion: '2025-05-28.basil'
} }
); );
} }
@ -61,7 +61,7 @@ export class SubscriptionService {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${ cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE user.Settings.settings.language
}/account`, }/account`,
client_reference_id: user.id, client_reference_id: user.id,
line_items: [ line_items: [

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

@ -51,7 +51,7 @@ export class TagService {
const tagsWithOrderCount = await this.prismaService.tag.findMany({ const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: { include: {
_count: { _count: {
select: { orders: true, symbolProfile: true } select: { activities: true, symbolProfile: true }
} }
} }
}); });
@ -61,7 +61,7 @@ export class TagService {
id, id,
name, name,
userId, userId,
activityCount: _count.orders, activityCount: _count.activities,
holdingCount: _count.symbolProfile holdingCount: _count.symbolProfile
}; };
}); });

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

@ -1,6 +1,7 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -16,6 +17,7 @@ import { UserService } from './user.service';
exports: [UserService], exports: [UserService],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
I18nModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }

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

@ -52,11 +52,10 @@ import { sortBy, without } from 'lodash';
@Injectable() @Injectable()
export class UserService { export class UserService {
private i18nService = new I18nService();
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -96,13 +95,13 @@ export class UserService {
} }
public async getUser( public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { accounts, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const userData = await Promise.all([ const userData = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
include: { include: {
User: true user: true
}, },
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
@ -142,6 +141,7 @@ export class UserService {
} }
return { return {
accounts,
activitiesCount, activitiesCount,
id, id,
permissions, permissions,
@ -155,7 +155,6 @@ export class UserService {
permissions: accessItem.permissions permissions: accessItem.permissions
}; };
}), }),
accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(Settings.settings as UserSettings), ...(Settings.settings as UserSettings),
@ -182,7 +181,7 @@ export class UserService {
const { const {
Access, Access,
accessToken, accessToken,
Account, accounts,
Analytics, Analytics,
authChallenge, authChallenge,
createdAt, createdAt,
@ -196,7 +195,7 @@ export class UserService {
} = await this.prismaService.user.findUnique({ } = await this.prismaService.user.findUnique({
include: { include: {
Access: true, Access: true,
Account: { accounts: {
include: { Platform: true } include: { Platform: true }
}, },
Analytics: true, Analytics: true,
@ -209,7 +208,7 @@ export class UserService {
const user: UserWithSettings = { const user: UserWithSettings = {
Access, Access,
accessToken, accessToken,
Account, accounts,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -298,10 +297,14 @@ export class UserService {
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
EmergencyFundSetup: new EmergencyFundSetup( EmergencyFundSetup: new EmergencyFundSetup(
undefined,
undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment( FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined, undefined,
undefined, undefined,
undefined undefined
@ -411,6 +414,10 @@ export class UserService {
user.subscription.offer.durationExtension = undefined; user.subscription.offer.durationExtension = undefined;
user.subscription.offer.label = undefined; user.subscription.offer.label = undefined;
} }
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount);
}
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
@ -433,11 +440,11 @@ export class UserService {
} }
} }
if (!environment.production && role === 'ADMIN') { if (!environment.production && hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.impersonateAllUsers); currentPermissions.push(permissions.impersonateAllUsers);
} }
user.Account = sortBy(user.Account, ({ name }) => { user.accounts = sortBy(user.accounts, ({ name }) => {
return name.toLowerCase(); return name.toLowerCase();
}); });
user.permissions = currentPermissions.sort(); user.permissions = currentPermissions.sort();
@ -474,7 +481,7 @@ export class UserService {
const user = await this.prismaService.user.create({ const user = await this.prismaService.user.create({
data: { data: {
...data, ...data,
Account: { accounts: {
create: { create: {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
name: this.i18nService.getTranslation({ name: this.i18nService.getTranslation({
@ -496,7 +503,7 @@ export class UserService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({ await this.prismaService.analytics.create({
data: { data: {
User: { connect: { id: user.id } } user: { connect: { id: user.id } }
} }
}); });
} }
@ -591,7 +598,7 @@ export class UserService {
const { settings } = await this.prismaService.settings.upsert({ const { settings } = await this.prismaService.settings.upsert({
create: { create: {
settings: userSettings as unknown as Prisma.JsonObject, settings: userSettings as unknown as Prisma.JsonObject,
User: { user: {
connect: { connect: {
id: userId id: userId
} }

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

File diff suppressed because it is too large

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

@ -4,12 +4,10 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- <url>
<url> <loc>https://ghostfol.io/ca</loc>
<loc>https://ghostfol.io/ca</loc> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> </url>
</url>
-->
<url> <url>
<loc>https://ghostfol.io/de</loc> <loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -317,7 +315,7 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc> <loc>https://ghostfol.io/fr/a-propos/journal-des-modifications</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -383,7 +381,7 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc> <loc>https://ghostfol.io/it/informazioni-su/registro-delle-modifiche</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -454,10 +452,6 @@
<loc>https://ghostfol.io/nl/over</loc> <loc>https://ghostfol.io/nl/over</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/over/licentie</loc> <loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -470,6 +464,10 @@
<loc>https://ghostfol.io/nl/over/privacybeleid</loc> <loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/over/wijzigingslogboek</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/prijzen</loc> <loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -494,12 +492,6 @@
<loc>https://ghostfol.io/pl/cennik</loc> <loc>https://ghostfol.io/pl/cennik</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!--
<url>
<loc>https://ghostfol.io/pl/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url> <url>
<loc>https://ghostfol.io/pl/funkcje</loc> <loc>https://ghostfol.io/pl/funkcje</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -584,12 +576,10 @@
<loc>https://ghostfol.io/tr</loc> <loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!--
<url> <url>
<loc>https://ghostfol.io/uk</loc> <loc>https://ghostfol.io/uk</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
-->
<url> <url>
<loc>https://ghostfol.io/zh</loc> <loc>https://ghostfol.io/zh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

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

@ -43,7 +43,16 @@ export class TransformDataSourceInRequestInterceptor<T>
const dataSourceValue = request[type]?.dataSource; const dataSourceValue = request[type]?.dataSource;
if (dataSourceValue && !DataSource[dataSourceValue]) { if (dataSourceValue && !DataSource[dataSourceValue]) {
request[type].dataSource = decodeDataSource(dataSourceValue); // In Express 5, request.query is read-only, so request[type].dataSource cannot be directly modified
Object.defineProperty(request, type, {
configurable: true,
enumerable: true,
value: {
...request[type],
dataSource: decodeDataSource(dataSourceValue)
},
writable: true
});
} }
} }
} }

15
apps/api/src/main.ts

@ -1,7 +1,8 @@
import { import {
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
STORYBOOK_PATH STORYBOOK_PATH,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -18,7 +19,6 @@ import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
async function bootstrap() { async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
@ -44,7 +44,14 @@ async function bootstrap() {
defaultVersion: '1', defaultVersion: '1',
type: VersioningType.URI type: VersioningType.URI
}); });
app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] }); app.setGlobalPrefix('api', {
exclude: [
'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
return `/${languageCode}/*wildcard`;
})
]
});
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -77,8 +84,6 @@ async function bootstrap() {
}); });
} }
app.use(HtmlTemplateMiddleware);
const HOST = configService.get<string>('HOST') || DEFAULT_HOST; const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT; const PORT = configService.get<number>('PORT') || DEFAULT_PORT;

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

@ -7,30 +7,14 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import { join } from 'path'; import { join } from 'path';
const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {};
const title = 'Ghostfolio'; const title = 'Ghostfolio';
try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch {}
const locales = { const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
@ -94,71 +78,93 @@ const locales = {
} }
}; };
const isFileRequest = (filename: string) => { @Injectable()
if (filename === '/assets/LICENSE') { export class HtmlTemplateMiddleware implements NestMiddleware {
return true; private indexHtmlMap: { [languageCode: string]: string } = {};
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-de.fi'
) ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename; public constructor(private readonly i18nService: I18nService) {
}; try {
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch (error) {
Logger.error(
'Failed to initialize index HTML map',
error,
'HTMLTemplateMiddleware'
);
}
}
export const HtmlTemplateMiddleware = async ( public use(request: Request, response: Response, next: NextFunction) {
request: Request, const path = request.originalUrl.replace(/\/$/, '');
response: Response, let languageCode = path.substr(1, 2);
next: NextFunction
) => {
const path = request.originalUrl.replace(/\/$/, '');
let languageCode = path.substr(1, 2);
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) { if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
languageCode = DEFAULT_LANGUAGE_CODE; languageCode = DEFAULT_LANGUAGE_CODE;
} }
const currentDate = format(new Date(), DATE_FORMAT); const currentDate = format(new Date(), DATE_FORMAT);
const rootUrl = process.env.ROOT_URL || environment.rootUrl; const rootUrl = process.env.ROOT_URL || environment.rootUrl;
if ( if (
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith(STORYBOOK_PATH) || path.startsWith(STORYBOOK_PATH) ||
isFileRequest(path) || this.isFileRequest(path) ||
!environment.production !environment.production
) { ) {
// Skip // Skip
next(); next();
} else { } else {
const indexHtml = interpolate(indexHtmlMap[languageCode], { const indexHtml = interpolate(this.indexHtmlMap[languageCode], {
currentDate, currentDate,
languageCode,
path,
rootUrl,
description: i18nService.getTranslation({
languageCode, languageCode,
id: 'metaDescription' path,
}), rootUrl,
featureGraphicPath: description: this.i18nService.getTranslation({
locales[path]?.featureGraphicPath ?? 'assets/cover.png', languageCode,
keywords: i18nService.getTranslation({ id: 'metaDescription'
languageCode, }),
id: 'metaKeywords' featureGraphicPath:
}), locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: keywords: this.i18nService.getTranslation({
locales[path]?.title ??
`${title}${i18nService.getTranslation({
languageCode, languageCode,
id: 'slogan' id: 'metaKeywords'
})}` }),
}); title:
locales[path]?.title ??
`${title}${this.i18nService.getTranslation({
languageCode,
id: 'slogan'
})}`
});
return response.send(indexHtml); return response.send(indexHtml);
}
} }
};
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-de.fi'
) ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

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

@ -1,5 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
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 { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { import {
PortfolioPosition, PortfolioPosition,
@ -14,28 +15,28 @@ import { RuleInterface } from './interfaces/rule.interface';
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> { export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private key: string; private key: string;
private name: string; private languageCode: string;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
{ {
key, key,
name languageCode = DEFAULT_LANGUAGE_CODE
}: { }: {
key: string; key: string;
name: string; languageCode?: string; // TODO: Make mandatory
} }
) { ) {
this.key = key; this.key = key;
this.name = name; this.languageCode = languageCode;
} }
public getKey() { public getKey() {
return this.key; return this.key;
} }
public getName() { public getLanguageCode() {
return this.name; return this.languageCode;
} }
public groupCurrentHoldingsByAttribute( public groupCurrentHoldingsByAttribute(
@ -73,5 +74,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
PortfolioReportRule['configuration'] PortfolioReportRule['configuration']
>; >;
public abstract getName(): string;
public abstract getSettings(aUserSettings: UserSettings): T; public abstract getSettings(aUserSettings: UserSettings): T;
} }

7
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -15,8 +15,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AccountClusterRiskCurrentInvestment.name, key: AccountClusterRiskCurrentInvestment.name
name: 'Investment'
}); });
this.accounts = accounts; this.accounts = accounts;
@ -88,6 +87,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getName() {
return 'Investment';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -11,8 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AccountClusterRiskSingleAccount.name, key: AccountClusterRiskSingleAccount.name
name: 'Single Account'
}); });
this.accounts = accounts; this.accounts = accounts;
@ -38,6 +37,10 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
return undefined; return undefined;
} }
public getName() {
return 'Single Account';
}
public getSettings({ xRayRules }: UserSettings): RuleSettings { public getSettings({ xRayRules }: UserSettings): RuleSettings {
return { return {
isActive: xRayRules?.[this.getKey()].isActive ?? true isActive: xRayRules?.[this.getKey()].isActive ?? true

7
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts

@ -11,8 +11,7 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
holdings: PortfolioPosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AssetClassClusterRiskEquity.name, key: AssetClassClusterRiskEquity.name
name: 'Equity'
}); });
this.holdings = holdings; this.holdings = holdings;
@ -78,6 +77,10 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
}; };
} }
public getName() {
return 'Equity';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts

@ -11,8 +11,7 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
holdings: PortfolioPosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AssetClassClusterRiskFixedIncome.name, key: AssetClassClusterRiskFixedIncome.name
name: 'Fixed Income'
}); });
this.holdings = holdings; this.holdings = holdings;
@ -78,6 +77,10 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
}; };
} }
public getName() {
return 'Fixed Income';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -11,8 +11,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
holdings: PortfolioPosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name, key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name
name: 'Investment: Base Currency'
}); });
this.holdings = holdings; this.holdings = holdings;
@ -68,6 +67,10 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
return undefined; return undefined;
} }
public getName() {
return 'Investment: Base Currency';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -11,8 +11,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
holdings: PortfolioPosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name, key: CurrencyClusterRiskCurrentInvestment.name
name: 'Investment'
}); });
this.holdings = holdings; this.holdings = holdings;
@ -73,6 +72,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getName() {
return 'Investment';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts

@ -13,8 +13,7 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
developedMarketsValueInBaseCurrency: number developedMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: EconomicMarketClusterRiskDevelopedMarkets.name, key: EconomicMarketClusterRiskDevelopedMarkets.name
name: 'Developed Markets'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
}; };
} }
public getName() {
return 'Developed Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts

@ -13,8 +13,7 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
emergingMarketsValueInBaseCurrency: number emergingMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: EconomicMarketClusterRiskEmergingMarkets.name, key: EconomicMarketClusterRiskEmergingMarkets.name
name: 'Emerging Markets'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getName() {
return 'Emerging Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

24
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
export class EmergencyFundSetup extends Rule<Settings> { export class EmergencyFundSetup extends Rule<Settings> {
@ -8,11 +9,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
emergencyFund: number emergencyFund: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: EmergencyFundSetup.name, languageCode,
name: 'Emergency Fund: Set up' key: EmergencyFundSetup.name
}); });
this.emergencyFund = emergencyFund; this.emergencyFund = emergencyFund;
@ -21,13 +24,19 @@ export class EmergencyFundSetup extends Rule<Settings> {
public evaluate() { public evaluate() {
if (!this.emergencyFund) { if (!this.emergencyFund) {
return { return {
evaluation: 'No emergency fund has been set up', evaluation: this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup.false',
languageCode: this.getLanguageCode()
}),
value: false value: false
}; };
} }
return { return {
evaluation: 'An emergency fund has been set up', evaluation: this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup.true',
languageCode: this.getLanguageCode()
}),
value: true value: true
}; };
} }
@ -36,6 +45,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
return undefined; return undefined;
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

36
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
export class FeeRatioInitialInvestment extends Rule<Settings> { export class FeeRatioInitialInvestment extends Rule<Settings> {
@ -9,12 +10,14 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
totalInvestment: number, totalInvestment: number,
fees: number fees: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: FeeRatioInitialInvestment.name, languageCode,
name: 'Fee Ratio' key: FeeRatioInitialInvestment.name
}); });
this.fees = fees; this.fees = fees;
@ -28,17 +31,27 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
if (feeRatio > ruleSettings.thresholdMax) { if (feeRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `The fees do exceed ${ evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.feeRatioInitialInvestment.false',
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
thresholdMax: (feeRatio * 100).toPrecision(3)
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The fees do not exceed ${ evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.feeRatioInitialInvestment.true',
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toFixed(2)
}
}),
value: true value: true
}; };
} }
@ -55,6 +68,13 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
}; };
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
asiaPacificValueInBaseCurrency: number asiaPacificValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskAsiaPacific.name, key: RegionalMarketClusterRiskAsiaPacific.name
name: 'Asia-Pacific'
}); });
this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency; this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
}; };
} }
public getName() {
return 'Asia-Pacific';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
emergingMarketsValueInBaseCurrency: number emergingMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEmergingMarkets.name, key: RegionalMarketClusterRiskEmergingMarkets.name
name: 'Emerging Markets'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -68,6 +67,10 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getName() {
return 'Emerging Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/regional-market-cluster-risk/europe.ts

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
europeValueInBaseCurrency: number europeValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEurope.name, key: RegionalMarketClusterRiskEurope.name
name: 'Europe'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
}; };
} }
public getName() {
return 'Europe';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/regional-market-cluster-risk/japan.ts

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
japanValueInBaseCurrency: number japanValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskJapan.name, key: RegionalMarketClusterRiskJapan.name
name: 'Japan'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
}; };
} }
public getName() {
return 'Japan';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

7
apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
northAmericaValueInBaseCurrency: number northAmericaValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskNorthAmerica.name, key: RegionalMarketClusterRiskNorthAmerica.name
name: 'North America'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
}; };
} }
public getName() {
return 'North America';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

23
apps/api/src/services/cron/cron.module.ts

@ -0,0 +1,23 @@
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { Module } from '@nestjs/common';
import { CronService } from './cron.service';
@Module({
imports: [
ConfigurationModule,
DataGatheringModule,
ExchangeRateDataModule,
PropertyModule,
TwitterBotModule,
UserModule
],
providers: [CronService]
})
export class CronModule {}

15
apps/api/src/services/cron.service.ts → apps/api/src/services/cron/cron.service.ts

@ -1,4 +1,9 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
@ -10,12 +15,6 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigurationService } from './configuration/configuration.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from './property/property.service';
import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0'; private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
@ -43,7 +42,9 @@ export class CronService {
@Cron(CronExpression.EVERY_DAY_AT_5PM) @Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePm() { public async runEveryDayAtFivePm() {
this.twitterBotService.tweetFearAndGreedIndex(); if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.twitterBotService.tweetFearAndGreedIndex();
}
} }
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)

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

@ -164,8 +164,10 @@ export class DataProviderService {
} }
public async getDataSources({ public async getDataSources({
includeGhostfolio = false,
user user
}: { }: {
includeGhostfolio?: boolean;
user: UserWithSettings; user: UserWithSettings;
}): Promise<DataSource[]> { }): Promise<DataSource[]> {
let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES'; let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES';
@ -188,7 +190,7 @@ export class DataProviderService {
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
)) as string; )) as string;
if (ghostfolioApiKey || hasRole(user, 'ADMIN')) { if (includeGhostfolio || ghostfolioApiKey) {
dataSources.push('GHOSTFOLIO'); dataSources.push('GHOSTFOLIO');
} }
@ -686,9 +688,6 @@ export class DataProviderService {
// Only allow symbols with supported currency // Only allow symbols with supported currency
return currency ? true : false; return currency ? true : false;
}) })
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
})
.map((lookupItem) => { .map((lookupItem) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (user.subscription.type === 'Premium') { if (user.subscription.type === 'Premium') {
@ -702,7 +701,21 @@ export class DataProviderService {
lookupItem.dataProviderInfo.isPremium = false; lookupItem.dataProviderInfo.isPremium = false;
} }
if (
lookupItem.assetSubClass === 'CRYPTOCURRENCY' &&
user?.Settings?.settings.isExperimentalFeatures
) {
// Remove DEFAULT_CURRENCY at the end of cryptocurrency names
lookupItem.name = lookupItem.name.replace(
new RegExp(` ${DEFAULT_CURRENCY}$`),
''
);
}
return lookupItem; return lookupItem;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
}); });
return { return {

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

@ -22,6 +22,7 @@ import {
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -378,12 +379,22 @@ export class FinancialModelingPrepService implements DataProviderInterface {
); );
for (const { price, symbol } of quotes) { for (const { price, symbol } of quotes) {
let marketState: MarketState = 'delayed';
if (
isCurrency(
symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)
)
) {
marketState = 'open';
}
response[symbol] = { response[symbol] = {
marketState,
currency: currencyBySymbolMap[symbol]?.currency, currency: currencyBySymbolMap[symbol]?.currency,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price, marketPrice: price
marketState: 'delayed'
}; };
} }
} catch (error) { } catch (error) {

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

@ -30,8 +30,11 @@ import {
HistoricalDividendsResult, HistoricalDividendsResult,
HistoricalHistoryResult HistoricalHistoryResult
} from 'yahoo-finance2/esm/src/modules/historical'; } from 'yahoo-finance2/esm/src/modules/historical';
import { Quote } from 'yahoo-finance2/esm/src/modules/quote'; import {
import { SearchQuoteNonYahoo } from 'yahoo-finance2/script/src/modules/search'; Quote,
QuoteResponseArray
} from 'yahoo-finance2/esm/src/modules/quote';
import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
@ -281,11 +284,19 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
}); });
const marketData = await this.yahooFinance.quote( let marketData: QuoteResponseArray = [];
quotes.map(({ symbol }) => {
return symbol; try {
}) marketData = await this.yahooFinance.quote(
); quotes.map(({ symbol }) => {
return symbol;
})
);
} catch (error) {
if (error?.result?.length > 0) {
marketData = error.result;
}
}
for (const marketDataItem of marketData) { for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote) => { const quote = quotes.find((currentQuote) => {

13
apps/api/src/services/demo/demo.module.ts

@ -0,0 +1,13 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { DemoService } from './demo.service';
@Module({
exports: [DemoService],
imports: [PrismaModule, PropertyModule],
providers: [DemoService]
})
export class DemoModule {}

59
apps/api/src/services/demo/demo.service.ts

@ -0,0 +1,59 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_DEMO_ACCOUNT_ID,
PROPERTY_DEMO_USER_ID,
TAG_ID_DEMO
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class DemoService {
public constructor(
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {}
public async syncDemoUserAccount() {
const [demoAccountId, demoUserId] = (await Promise.all([
this.propertyService.getByKey(PROPERTY_DEMO_ACCOUNT_ID),
this.propertyService.getByKey(PROPERTY_DEMO_USER_ID)
])) as [string, string];
let activities = await this.prismaService.order.findMany({
orderBy: {
date: 'asc'
},
where: {
tags: {
some: {
id: TAG_ID_DEMO
}
}
}
});
activities = activities.map((activity) => {
return {
...activity,
accountId: demoAccountId,
accountUserId: demoUserId,
comment: null,
id: uuidv4(),
userId: demoUserId
};
});
await this.prismaService.order.deleteMany({
where: {
userId: demoUserId
}
});
return this.prismaService.order.createMany({
data: activities
});
}
}

9
apps/api/src/services/i18n/i18n.module.ts

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Module({
exports: [I18nService],
providers: [I18nService]
})
export class I18nModule {}

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

@ -1,10 +1,11 @@
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'fs'; import { readFileSync, readdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@Injectable()
export class I18nService { export class I18nService {
private localesPath = join(__dirname, 'assets', 'locales'); private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {}; private translations: { [locale: string]: cheerio.CheerioAPI } = {};
@ -15,10 +16,12 @@ export class I18nService {
public getTranslation({ public getTranslation({
id, id,
languageCode languageCode,
placeholders
}: { }: {
id: string; id: string;
languageCode: string; languageCode: string;
placeholders?: Record<string, string | number>;
}): string { }): string {
const $ = this.translations[languageCode]; const $ = this.translations[languageCode];
@ -26,7 +29,7 @@ export class I18nService {
Logger.warn(`Translation not found for locale '${languageCode}'`); Logger.warn(`Translation not found for locale '${languageCode}'`);
} }
const translatedText = $( let translatedText = $(
`trans-unit[id="${id}"] > ${ `trans-unit[id="${id}"] > ${
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target' languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target'
}` }`
@ -38,6 +41,12 @@ export class I18nService {
); );
} }
if (placeholders) {
for (const [key, value] of Object.entries(placeholders)) {
translatedText = translatedText.replace(`\${${key}}`, String(value));
}
}
return translatedText.trim(); return translatedText.trim();
} }

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

@ -36,7 +36,7 @@ export class ImpersonationService {
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
GranteeUser: null, GranteeUser: null,
User: { id: aId } user: { id: aId }
} }
}); });

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

@ -33,7 +33,7 @@ export class SymbolProfileService {
include: { include: {
activities: { activities: {
include: { include: {
User: true user: true
} }
} }
}, },
@ -42,14 +42,14 @@ export class SymbolProfileService {
activities: withUserSubscription activities: withUserSubscription
? { ? {
some: { some: {
User: { user: {
subscriptions: { some: { expiresAt: { gt: new Date() } } } subscriptions: { some: { expiresAt: { gt: new Date() } } }
} }
} }
} }
: { : {
every: { every: {
User: { user: {
subscriptions: { none: { expiresAt: { gt: new Date() } } } subscriptions: { none: { expiresAt: { gt: new Date() } } }
} }
} }

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

@ -52,7 +52,7 @@ export class TagService {
include: { include: {
_count: { _count: {
select: { select: {
orders: { activities: {
where: { where: {
userId userId
} }
@ -88,7 +88,7 @@ export class TagService {
const tagsWithOrderCount = await this.prismaService.tag.findMany({ const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: { include: {
_count: { _count: {
select: { orders: true } select: { activities: true }
} }
} }
}); });
@ -98,7 +98,7 @@ export class TagService {
id, id,
name, name,
userId, userId,
activityCount: _count.orders activityCount: _count.activities
}; };
}); });
} }

1
apps/client-e2e/project.json

@ -10,7 +10,6 @@
"executor": "@nx/cypress:cypress", "executor": "@nx/cypress:cypress",
"options": { "options": {
"cypressConfig": "apps/client-e2e/cypress.json", "cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve" "devServerTarget": "client:serve"
}, },
"configurations": { "configurations": {

61
apps/client/project.json

@ -42,6 +42,10 @@
"baseHref": "/tr/", "baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf" "translation": "apps/client/src/locales/messages.tr.xlf"
}, },
"uk": {
"baseHref": "/uk/",
"translation": "apps/client/src/locales/messages.uk.xlf"
},
"zh": { "zh": {
"baseHref": "/zh/", "baseHref": "/zh/",
"translation": "apps/client/src/locales/messages.zh.xlf" "translation": "apps/client/src/locales/messages.zh.xlf"
@ -181,7 +185,7 @@
"command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets" "command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets"
}, },
{ {
"command": "shx cp -r apps/client/src/assets/.well-known/* dist/apps/client/.well-known" "command": "shx cp -r apps/client/src/assets/.well-known/assetlinks.json dist/apps/client/.well-known"
}, },
{ {
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client" "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
@ -257,7 +261,8 @@
"production": { "production": {
"buildTarget": "client:build:production" "buildTarget": "client:build:production"
} }
} },
"continuous": true
}, },
"extract-i18n": { "extract-i18n": {
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge", "executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
@ -293,55 +298,5 @@
}, },
"outputs": ["{workspaceRoot}/coverage/apps/client"] "outputs": ["{workspaceRoot}/coverage/apps/client"]
} }
}, }
"i18n": {
"locales": {
"ca": {
"baseHref": "/ca/",
"translation": "apps/client/src/locales/messages.ca.xlf"
},
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
},
"es": {
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"fr": {
"baseHref": "/fr/",
"translation": "apps/client/src/locales/messages.fr.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
},
"pl": {
"baseHref": "/pl/",
"translation": "apps/client/src/locales/messages.pl.xlf"
},
"pt": {
"baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf"
},
"tr": {
"baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf"
},
"uk": {
"baseHref": "/uk/",
"translation": "apps/client/src/locales/messages.uk.xlf"
},
"zh": {
"baseHref": "/zh/",
"translation": "apps/client/src/locales/messages.zh.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
} }

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

@ -1,6 +1,10 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; 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 {
publicRoutes,
routes as ghostfolioRoutes,
internalRoutes
} from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes, TitleStrategy } from '@angular/router'; import { RouterModule, Routes, TitleStrategy } from '@angular/router';
@ -9,26 +13,26 @@ import { ModulePreloadService } from './core/module-preload.service';
const routes: Routes = [ const routes: Routes = [
{ {
path: paths.about, path: publicRoutes.about.path,
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
}, },
{ {
path: 'account', path: internalRoutes.account.path,
loadChildren: () => loadChildren: () =>
import('./pages/user-account/user-account-page.module').then( import('./pages/user-account/user-account-page.module').then(
(m) => m.UserAccountPageModule (m) => m.UserAccountPageModule
) )
}, },
{ {
path: 'accounts', path: internalRoutes.accounts.path,
loadChildren: () => loadChildren: () =>
import('./pages/accounts/accounts-page.module').then( import('./pages/accounts/accounts-page.module').then(
(m) => m.AccountsPageModule (m) => m.AccountsPageModule
) )
}, },
{ {
path: 'admin', path: internalRoutes.adminControl.path,
loadChildren: () => loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule) import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
}, },
@ -38,16 +42,17 @@ const routes: Routes = [
import('./pages/api/api-page.component').then( import('./pages/api/api-page.component').then(
(c) => c.GfApiPageComponent (c) => c.GfApiPageComponent
), ),
path: 'api', path: ghostfolioRoutes.api,
title: 'Ghostfolio API' title: 'Ghostfolio API'
}, },
{ {
path: 'auth', path: internalRoutes.auth.path,
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule),
title: internalRoutes.auth.title
}, },
{ {
path: 'blog', path: publicRoutes.blog.path,
loadChildren: () => loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
}, },
@ -57,10 +62,10 @@ const routes: Routes = [
import('./pages/demo/demo-page.component').then( import('./pages/demo/demo-page.component').then(
(c) => c.GfDemoPageComponent (c) => c.GfDemoPageComponent
), ),
path: 'demo' path: publicRoutes.demo.path
}, },
{ {
path: paths.faq, path: publicRoutes.faq.path,
loadChildren: () => loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
}, },
@ -70,11 +75,11 @@ const routes: Routes = [
import('./pages/features/features-page.component').then( import('./pages/features/features-page.component').then(
(c) => c.GfFeaturesPageComponent (c) => c.GfFeaturesPageComponent
), ),
path: paths.features, path: publicRoutes.features.path,
title: $localize`Features` title: publicRoutes.features.title
}, },
{ {
path: 'home', path: internalRoutes.home.path,
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
@ -84,58 +89,58 @@ const routes: Routes = [
import('./pages/i18n/i18n-page.component').then( import('./pages/i18n/i18n-page.component').then(
(c) => c.GfI18nPageComponent (c) => c.GfI18nPageComponent
), ),
path: 'i18n', path: ghostfolioRoutes.i18n,
title: $localize`Internationalization` title: $localize`Internationalization`
}, },
{ {
path: paths.markets, path: publicRoutes.markets.path,
loadChildren: () => loadChildren: () =>
import('./pages/markets/markets-page.module').then( import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule (m) => m.MarketsPageModule
) )
}, },
{ {
path: 'open', path: publicRoutes.openStartup.path,
loadChildren: () => loadChildren: () =>
import('./pages/open/open-page.module').then((m) => m.OpenPageModule) import('./pages/open/open-page.module').then((m) => m.OpenPageModule)
}, },
{ {
path: 'p', path: internalRoutes.portfolio.path,
loadChildren: () =>
import('./pages/public/public-page.module').then(
(m) => m.PublicPageModule
)
},
{
path: 'portfolio',
loadChildren: () => loadChildren: () =>
import('./pages/portfolio/portfolio-page.module').then( import('./pages/portfolio/portfolio-page.module').then(
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
{ {
path: paths.pricing, path: publicRoutes.pricing.path,
loadChildren: () => loadChildren: () =>
import('./pages/pricing/pricing-page.module').then( import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule (m) => m.PricingPageModule
) )
}, },
{ {
path: paths.register, path: ghostfolioRoutes.public,
loadChildren: () =>
import('./pages/public/public-page.module').then(
(m) => m.PublicPageModule
)
},
{
path: publicRoutes.register.path,
loadChildren: () => loadChildren: () =>
import('./pages/register/register-page.module').then( import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule (m) => m.RegisterPageModule
) )
}, },
{ {
path: paths.resources, path: publicRoutes.resources.path,
loadChildren: () => loadChildren: () =>
import('./pages/resources/resources-page.module').then( import('./pages/resources/resources-page.module').then(
(m) => m.ResourcesPageModule (m) => m.ResourcesPageModule
) )
}, },
{ {
path: 'start', path: publicRoutes.start.path,
loadChildren: () => loadChildren: () =>
import('./pages/landing/landing-page.module').then( import('./pages/landing/landing-page.module').then(
(m) => m.LandingPageModule (m) => m.LandingPageModule
@ -146,11 +151,11 @@ const routes: Routes = [
import('./pages/webauthn/webauthn-page.component').then( import('./pages/webauthn/webauthn-page.component').then(
(c) => c.GfWebauthnPageComponent (c) => c.GfWebauthnPageComponent
), ),
path: 'webauthn', path: internalRoutes.webauthn.path,
title: $localize`Sign in` title: internalRoutes.webauthn.title
}, },
{ {
path: 'zen', path: internalRoutes.zen.path,
loadChildren: () => loadChildren: () =>
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule) import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule)
}, },

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

@ -10,7 +10,7 @@
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span i18n>You are using the Live Demo.</span> <span i18n>You are using the Live Demo.</span>
<span class="a ml-2" i18n>Create Account</span> <span class="a ml-2 p-1" i18n>Create Account</span>
</div></a </div></a
> >
} }
@ -70,7 +70,7 @@
<li><a i18n [routerLink]="routerLinkAbout">About</a></li> <li><a i18n [routerLink]="routerLinkAbout">About</a></li>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<li> <li>
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="routerLinkBlog">Blog</a>
</li> </li>
} }
<li> <li>
@ -91,7 +91,7 @@
} }
@if (hasPermissionForStatistics) { @if (hasPermissionForStatistics) {
<li> <li>
<a [routerLink]="['/open']">Open Startup</a> <a [routerLink]="routerLinkOpenStartup">Open Startup</a>
</li> </li>
} }
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {

6
apps/client/src/app/app.component.scss

@ -15,12 +15,14 @@
z-index: 999; z-index: 999;
.info-message { .info-message {
color: rgba(var(--palette-foreground-text), 1); color: rgba(var(--light-primary-text));
font-size: 80%; font-size: 80%;
font-weight: 500;
max-width: 100%; max-width: 100%;
.a { .a {
font-weight: 500; border: 1px solid rgba(var(--light-primary-text));
border-radius: 0.25rem;
} }
} }
} }

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

@ -3,6 +3,11 @@ import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding
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';
import {
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
@ -62,29 +67,23 @@ export class AppComponent implements OnDestroy, OnInit {
public hasTabs = false; public hasTabs = false;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkAbout = ['/' + $localize`:snake-case:about`]; public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog = [ public routerLinkAboutChangelog =
'/' + $localize`:snake-case:about`, publicRoutes.about.subRoutes.changelog.routerLink;
'changelog' public routerLinkAboutLicense =
]; publicRoutes.about.subRoutes.license.routerLink;
public routerLinkAboutLicense = [ public routerLinkAboutPrivacyPolicy =
'/' + $localize`:snake-case:about`, publicRoutes.about.subRoutes.privacyPolicy.routerLink;
$localize`:snake-case:license` public routerLinkAboutTermsOfService =
]; publicRoutes.about.subRoutes.termsOfService.routerLink;
public routerLinkAboutPrivacyPolicy = [ public routerLinkBlog = publicRoutes.blog.routerLink;
'/' + $localize`:snake-case:about`, public routerLinkFaq = publicRoutes.faq.routerLink;
$localize`:snake-case:privacy-policy` public routerLinkFeatures = publicRoutes.features.routerLink;
]; public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkAboutTermsOfService = [ public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
'/' + $localize`:snake-case:about`, public routerLinkPricing = publicRoutes.pricing.routerLink;
$localize`:snake-case:terms-of-service` public routerLinkRegister = publicRoutes.register.routerLink;
]; public routerLinkResources = publicRoutes.resources.routerLink;
public routerLinkFaq = ['/' + $localize`:snake-case:faq`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
public showFooter = false; public showFooter = false;
public user: User; public user: User;
@ -163,12 +162,14 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentSubRoute = urlSegments[1]?.path; this.currentSubRoute = urlSegments[1]?.path;
if ( if (
(this.currentRoute === 'home' && !this.currentSubRoute) || ((this.currentRoute === internalRoutes.home.path &&
(this.currentRoute === 'home' && !this.currentSubRoute) ||
this.currentSubRoute === 'holdings') || (this.currentRoute === internalRoutes.home.path &&
(this.currentRoute === 'portfolio' && !this.currentSubRoute) || this.currentSubRoute ===
(this.currentRoute === 'zen' && !this.currentSubRoute) || internalRoutes.home.subRoutes.holdings.path) ||
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings') (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) &&
this.user?.settings?.viewMode !== 'ZEN'
) { ) {
this.hasPermissionToChangeDateRange = true; this.hasPermissionToChangeDateRange = true;
} else { } else {
@ -176,14 +177,20 @@ export class AppComponent implements OnDestroy, OnInit {
} }
if ( if (
(this.currentRoute === 'home' && (this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute === 'holdings') || this.currentSubRoute ===
(this.currentRoute === 'portfolio' && !this.currentSubRoute) || internalRoutes.home.subRoutes.holdings.path) ||
(this.currentRoute === 'portfolio' && (this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute === 'activities') || !this.currentSubRoute) ||
(this.currentRoute === 'portfolio' && (this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute === 'allocations') || this.currentSubRoute ===
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings') internalRoutes.portfolio.subRoutes.activities.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.allocations.path) ||
(this.currentRoute === internalRoutes.zen.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path)
) { ) {
this.hasPermissionToChangeFilters = true; this.hasPermissionToChangeFilters = true;
} else { } else {
@ -191,25 +198,25 @@ export class AppComponent implements OnDestroy, OnInit {
} }
this.hasTabs = this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) || (this.currentRoute === publicRoutes.about.path ||
this.currentRoute === this.routerLinkFaq[0].slice(1) || this.currentRoute === publicRoutes.faq.path ||
this.currentRoute === this.routerLinkResources[0].slice(1) || this.currentRoute === publicRoutes.resources.path ||
this.currentRoute === 'account' || this.currentRoute === internalRoutes.account.path ||
this.currentRoute === 'admin' || this.currentRoute === internalRoutes.adminControl.path ||
this.currentRoute === 'home' || this.currentRoute === internalRoutes.home.path ||
this.currentRoute === 'portfolio' || this.currentRoute === internalRoutes.portfolio.path ||
this.currentRoute === 'zen') && this.currentRoute === internalRoutes.zen.path) &&
this.deviceType !== 'mobile'; this.deviceType !== 'mobile';
this.showFooter = this.showFooter =
(this.currentRoute === 'blog' || (this.currentRoute === publicRoutes.blog.path ||
this.currentRoute === this.routerLinkFeatures[0].slice(1) || this.currentRoute === publicRoutes.features.path ||
this.currentRoute === this.routerLinkMarkets[0].slice(1) || this.currentRoute === publicRoutes.markets.path ||
this.currentRoute === 'open' || this.currentRoute === publicRoutes.openStartup.path ||
this.currentRoute === 'p' || this.currentRoute === routes.public ||
this.currentRoute === this.routerLinkPricing[0].slice(1) || this.currentRoute === publicRoutes.pricing.path ||
this.currentRoute === this.routerLinkRegister[0].slice(1) || this.currentRoute === publicRoutes.register.path ||
this.currentRoute === 'start') && this.currentRoute === publicRoutes.start.path) &&
this.deviceType !== 'mobile'; this.deviceType !== 'mobile';
if (this.deviceType === 'mobile') { if (this.deviceType === 'mobile') {

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

@ -1,7 +1,7 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { routes } from '@ghostfolio/common/routes/routes';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { import {
@ -53,9 +53,9 @@ export class AccessTableComponent implements OnChanges {
} }
public getPublicUrl(aId: string): string { public getPublicUrl(aId: string): string {
const languageCode = this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE; const languageCode = this.user.settings.language;
return `${this.baseUrl}/${languageCode}/p/${aId}`; return `${this.baseUrl}/${languageCode}/${routes.public}/${aId}`;
} }
public onCopyUrlToClipboard(aId: string): void { public onCopyUrlToClipboard(aId: string): void {

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

@ -10,6 +10,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { import {
@ -92,9 +93,12 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
public onCloneActivity(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], { this.router.navigate(
queryParams: { activityId: aActivity.id, createDialog: true } internalRoutes.portfolio.subRoutes.activities.routerLink,
}); {
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close(); this.dialogRef.close();
} }
@ -151,9 +155,12 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
public onUpdateActivity(aActivity: Activity) { public onUpdateActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], { this.router.navigate(
queryParams: { activityId: aActivity.id, editDialog: true } internalRoutes.portfolio.subRoutes.activities.routerLink,
}); {
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close(); this.dialogRef.close();
} }

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

@ -44,10 +44,10 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if (element.Platform?.url) { @if (element.Platform?.url) {
<gf-asset-profile-icon <gf-entity-logo
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>
@ -92,10 +92,10 @@
> >
<div class="d-flex"> <div class="d-flex">
@if (element.Platform?.url) { @if (element.Platform?.url) {
<gf-asset-profile-icon <gf-entity-logo
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>

4
apps/client/src/app/components/accounts-table/accounts-table.module.ts

@ -1,4 +1,4 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -17,7 +17,7 @@ import { AccountsTableComponent } from './accounts-table.component';
exports: [AccountsTableComponent], exports: [AccountsTableComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfAssetProfileIconComponent, GfEntityLogoComponent,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,

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

@ -551,7 +551,7 @@
<mat-label i18n>Url</mat-label> <mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" /> <input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) { @if (assetProfileForm.get('url').value) {
<gf-asset-profile-icon <gf-entity-logo
class="mr-3" class="mr-3"
matSuffix matSuffix
[url]="assetProfileForm.get('url').value" [url]="assetProfileForm.get('url').value"

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -1,6 +1,6 @@
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -29,8 +29,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfAssetProfileIconComponent,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfEntityLogoComponent,
GfHistoricalMarketDataEditorComponent, GfHistoricalMarketDataEditorComponent,
GfLineChartComponent, GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,

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

@ -22,12 +22,13 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public coupons: Coupon[]; public coupons: Coupon[];
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;
public hasPermissionToSyncDemoUserAccount: boolean;
public hasPermissionToToggleReadOnlyMode: boolean; public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem; public info: InfoItem;
public isDataGatheringEnabled: boolean; public isDataGatheringEnabled: boolean;
@ -60,6 +62,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
@ -80,6 +83,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
permissions.enableSystemMessage permissions.enableSystemMessage
); );
this.hasPermissionToSyncDemoUserAccount = hasPermission(
this.user.permissions,
permissions.syncDemoUserAccount
);
this.hasPermissionToToggleReadOnlyMode = hasPermission( this.hasPermissionToToggleReadOnlyMode = hasPermission(
this.user.permissions, this.user.permissions,
permissions.toggleReadOnlyMode permissions.toggleReadOnlyMode
@ -206,6 +214,21 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
} }
public onSyncDemoUserAccount() {
this.adminService
.syncDemoUserAccount()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.snackBar.open(
'✅ ' + $localize`Demo user account has been synced.`,
undefined,
{
duration: ms('3 seconds')
}
);
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

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

@ -169,10 +169,23 @@
<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">
<button color="warn" mat-flat-button (click)="onFlushCache()"> <div class="align-items-start d-flex flex-column">
<ion-icon class="mr-1" name="close-circle-outline" /> @if (hasPermissionToSyncDemoUserAccount) {
<span i18n>Flush Cache</span> <button
</button> class="mb-2"
color="accent"
mat-flat-button
(click)="onSyncDemoUserAccount()"
>
<ion-icon class="mr-1" name="sync-outline" />
<span i18n>Sync Demo User Account</span>
</button>
}
<button color="warn" mat-flat-button (click)="onFlushCache()">
<ion-icon class="mr-1" name="close-circle-outline" />
<span i18n>Flush Cache</span>
</button>
</div>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>

2
apps/client/src/app/components/admin-overview/admin-overview.module.ts

@ -9,6 +9,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AdminOverviewComponent } from './admin-overview.component'; import { AdminOverviewComponent } from './admin-overview.component';
@ -24,6 +25,7 @@ import { AdminOverviewComponent } from './admin-overview.component';
MatCardModule, MatCardModule,
MatMenuModule, MatMenuModule,
MatSelectModule, MatSelectModule,
MatSnackBarModule,
MatSlideToggleModule, MatSlideToggleModule,
ReactiveFormsModule, ReactiveFormsModule,
RouterModule RouterModule

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

@ -23,7 +23,7 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if (element.url) { @if (element.url) {
<gf-asset-profile-icon <gf-entity-logo
class="d-inline mr-1" class="d-inline mr-1"
[tooltip]="element.name" [tooltip]="element.name"
[url]="element.url" [url]="element.url"

4
apps/client/src/app/components/admin-platform/admin-platform.module.ts

@ -1,4 +1,4 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -16,8 +16,8 @@ import { GfCreateOrUpdatePlatformDialogModule } from './create-or-update-platfor
exports: [AdminPlatformComponent], exports: [AdminPlatformComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfAssetProfileIconComponent,
GfCreateOrUpdatePlatformDialogModule, GfCreateOrUpdatePlatformDialogModule,
GfEntityLogoComponent,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatSortModule, MatSortModule,

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

@ -29,11 +29,7 @@
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
@if (data.platform.url) { @if (data.platform.url) {
<gf-asset-profile-icon <gf-entity-logo class="mr-3" matSuffix [url]="data.platform.url" />
class="mr-3"
matSuffix
[url]="data.platform.url"
/>
} }
</mat-form-field> </mat-form-field>
</div> </div>

4
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts

@ -1,4 +1,4 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
@ -14,8 +14,8 @@ import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog
declarations: [CreateOrUpdatePlatformDialog], declarations: [CreateOrUpdatePlatformDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfAssetProfileIconComponent,
FormsModule, FormsModule,
GfEntityLogoComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,

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

@ -9,7 +9,7 @@
</th> </th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell> <td *matCellDef="let element" class="px-1 py-2" mat-cell>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<gf-asset-profile-icon class="mr-1" [url]="element.url" /> <gf-entity-logo class="mr-1" [url]="element.url" />
<div> <div>
@if (isGhostfolioDataProvider(element)) { @if (isGhostfolioDataProvider(element)) {
<a <a
@ -23,7 +23,7 @@
[enableLink]="false" [enableLink]="false"
/> />
@if (isGhostfolioApiKeyValid === false) { @if (isGhostfolioApiKeyValid === false) {
<span class="badge badge-warning ml-2" i18n <span class="badge badge-light early-access ml-2" i18n
>Early Access</span >Early Access</span
> >
} }
@ -52,7 +52,11 @@
<ng-container i18n>Asset Profiles</ng-container> <ng-container i18n>Asset Profiles</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell> <td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.assetProfileCount }} <gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="element.assetProfileCount"
/>
</td> </td>
</ng-container> </ng-container>
@ -105,12 +109,11 @@
</mat-menu> </mat-menu>
} @else if (isGhostfolioApiKeyValid === false) { } @else if (isGhostfolioApiKeyValid === false) {
<button <button
color="accent" class="special"
mat-flat-button mat-flat-button
(click)="onSetGhostfolioApiKey()" (click)="onSetGhostfolioApiKey()"
> >
<ion-icon class="mr-1" name="key-outline" /> <ng-container i18n>Set API key</ng-container>
<span i18n>Set API key</span>
</button> </button>
} }
} }

13
apps/client/src/app/components/admin-settings/admin-settings.component.scss

@ -1,6 +1,19 @@
:host { :host {
display: block; display: block;
button {
&.special {
background: linear-gradient(45deg, rgb(228, 94, 237), rgb(104, 94, 237));
color: #fff;
}
}
.badge {
&.early-access {
border: 1px solid var(--mat-table-row-item-outline-color);
}
}
.mat-mdc-progress-bar { .mat-mdc-progress-bar {
--mdc-linear-progress-active-indicator-height: 0.5rem; --mdc-linear-progress-active-indicator-height: 0.5rem;
--mdc-linear-progress-track-height: 0.5rem; --mdc-linear-progress-track-height: 0.5rem;

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

@ -3,16 +3,14 @@ import { NotificationService } from '@ghostfolio/client/core/notification/notifi
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
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';
import { import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
DEFAULT_LANGUAGE_CODE,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DataProviderInfo, DataProviderInfo,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -69,15 +67,12 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
this.user = state.user; this.user = state.user;
this.defaultDateFormat = getDateFormatString( this.defaultDateFormat = getDateFormatString(
this.user?.settings?.locale this.user.settings.locale
); );
const languageCode = const languageCode = this.user.settings.language;
this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
this.pricingUrl = this.pricingUrl = `https://ghostfol.io/${languageCode}/${publicRoutes.pricing.path}`;
`https://ghostfol.io/${languageCode}/` +
$localize`:snake-case:pricing`;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
@ -146,29 +141,35 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
this.dataSource = new MatTableDataSource(filteredProviders); this.dataSource = new MatTableDataSource(filteredProviders);
this.adminService const ghostfolioApiKey = settings[
.fetchGhostfolioDataProviderStatus( PROPERTY_API_KEY_GHOSTFOLIO
settings[PROPERTY_API_KEY_GHOSTFOLIO] as string ] as string;
)
.pipe( if (ghostfolioApiKey) {
catchError(() => { this.adminService
this.isGhostfolioApiKeyValid = false; .fetchGhostfolioDataProviderStatus(ghostfolioApiKey)
.pipe(
catchError(() => {
this.isGhostfolioApiKeyValid = false;
this.changeDetectorRef.markForCheck();
return of(null);
}),
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
});
return of(null); } else {
}), this.isGhostfolioApiKeyValid = false;
filter((status) => { }
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.changeDetectorRef.markForCheck();
});
this.isLoading = false; this.isLoading = false;

6
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -1,7 +1,8 @@
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module'; import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -20,8 +21,9 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule, CommonModule,
GfAdminPlatformModule, GfAdminPlatformModule,
GfAdminTagModule, GfAdminTagModule,
GfAssetProfileIconComponent, GfEntityLogoComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfValueComponent,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatProgressBarModule, MatProgressBarModule,

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

@ -36,7 +36,7 @@
}}</mat-option> }}</mat-option>
} }
@if (hasPermissionToAccessAdminControl) { @if (hasPermissionToAccessAdminControl) {
<mat-option [routerLink]="['/admin', 'market-data']"> <mat-option [routerLink]="routerLinkAdminControlMarketData">
<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>

3
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -13,6 +13,7 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces'; import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { import {
@ -63,6 +64,8 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
public chart: Chart<'line'>; public chart: Chart<'line'>;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
public routerLinkAdminControlMarketData =
internalRoutes.adminControl.subRoutes.marketData.routerLink;
public constructor() { public constructor() {
Chart.register( Chart.register(

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

@ -20,9 +20,11 @@
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': 'font-weight-bold':
currentRoute === 'home' || currentRoute === 'zen', currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path,
'text-decoration-underline': 'text-decoration-underline':
currentRoute === 'home' || currentRoute === 'zen' currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path
}" }"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
@ -34,10 +36,11 @@
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'portfolio', 'font-weight-bold': currentRoute === internalRoutes.portfolio.path,
'text-decoration-underline': currentRoute === 'portfolio' 'text-decoration-underline':
currentRoute === internalRoutes.portfolio.path
}" }"
[routerLink]="['/portfolio']" [routerLink]="routerLinkPortfolio"
>Portfolio</a >Portfolio</a
> >
</li> </li>
@ -47,10 +50,11 @@
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'accounts', 'font-weight-bold': currentRoute === internalRoutes.accounts.path,
'text-decoration-underline': currentRoute === 'accounts' 'text-decoration-underline':
currentRoute === internalRoutes.accounts.path
}" }"
[routerLink]="['/accounts']" [routerLink]="routerLinkAccounts"
>Accounts</a >Accounts</a
> >
</li> </li>
@ -61,10 +65,10 @@
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'admin', 'font-weight-bold': currentRoute === routes.adminControl,
'text-decoration-underline': currentRoute === 'admin' 'text-decoration-underline': currentRoute === routes.adminControl
}" }"
[routerLink]="['/admin']" [routerLink]="routerLinkAdminControl"
>Admin Control</a >Admin Control</a
> >
</li> </li>
@ -235,7 +239,8 @@
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': 'font-weight-bold':
currentRoute === 'home' || currentRoute === 'zen' currentRoute === internalRoutes.home.path ||
currentRoute === internalRoutes.zen.path
}" }"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
@ -245,24 +250,26 @@
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'portfolio' 'font-weight-bold': currentRoute === internalRoutes.portfolio.path
}" }"
[routerLink]="['/portfolio']" [routerLink]="routerLinkPortfolio"
>Portfolio</a >Portfolio</a
> >
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }" [ngClass]="{
[routerLink]="['/accounts']" 'font-weight-bold': currentRoute === internalRoutes.accounts.path
}"
[routerLink]="routerLinkAccounts"
>Accounts</a >Accounts</a
> >
<a <a
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }" [ngClass]="{ 'font-weight-bold': currentRoute === routes.account }"
[routerLink]="['/account']" [routerLink]="routerLinkAccount"
>My Ghostfolio</a >My Ghostfolio</a
> >
@if (hasPermissionToAccessAdminControl) { @if (hasPermissionToAccessAdminControl) {
@ -270,8 +277,10 @@
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }" [ngClass]="{
[routerLink]="['/admin']" 'font-weight-bold': currentRoute === routes.adminControl
}"
[routerLink]="routerLinkAdminControl"
>Admin Control</a >Admin Control</a
> >
} }

12
apps/client/src/app/components/header/header.component.scss

@ -7,7 +7,11 @@
.logo-container { .logo-container {
&.filled { &.filled {
background-color: rgba(var(--palette-foreground-base), 0.02); background: linear-gradient(
to bottom,
transparent,
rgba(var(--palette-foreground-base), 0.02)
);
} }
@media (min-width: 576px) { @media (min-width: 576px) {
@ -56,7 +60,11 @@
.logo-container { .logo-container {
&.filled { &.filled {
background-color: rgba(var(--palette-foreground-base-dark), 0.02); background: linear-gradient(
to bottom,
transparent,
rgba(var(--palette-foreground-base-dark), 0.02)
);
} }
} }
} }

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

@ -12,6 +12,11 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces'; import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component'; import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
@ -78,18 +83,24 @@ export class HeaderComponent implements OnChanges {
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean; public hasPermissionToCreateUser: boolean;
public impersonationId: string; public impersonationId: string;
public internalRoutes = internalRoutes;
public isMenuOpen: boolean; public isMenuOpen: boolean;
public routeAbout = $localize`:snake-case:about`; public routeAbout = publicRoutes.about.path;
public routeFeatures = $localize`:snake-case:features`; public routeFeatures = publicRoutes.features.path;
public routeMarkets = $localize`:snake-case:markets`; public routeMarkets = publicRoutes.markets.path;
public routePricing = $localize`:snake-case:pricing`; public routePricing = publicRoutes.pricing.path;
public routeResources = $localize`:snake-case:resources`; public routeResources = publicRoutes.resources.path;
public routerLinkAbout = ['/' + $localize`:snake-case:about`]; public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkFeatures = ['/' + $localize`:snake-case:features`]; public routerLinkAccount = internalRoutes.account.routerLink;
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`]; public routerLinkAccounts = internalRoutes.accounts.routerLink;
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; public routerLinkAdminControl = internalRoutes.adminControl.routerLink;
public routerLinkRegister = ['/' + $localize`:snake-case:register`]; public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkResources = ['/' + $localize`:snake-case:resources`]; public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
public routes = routes;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

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

@ -14,6 +14,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits'; import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
@ -102,8 +103,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public hasPermissionToCreateOwnTag: boolean; public hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investmentInBaseCurrencyWithCurrencyEffect: number;
public investmentPrecision = 2; public investmentInBaseCurrencyWithCurrencyEffectPrecision = 2;
public marketDataItems: MarketData[] = []; public marketDataItems: MarketData[] = [];
public marketPrice: number; public marketPrice: number;
public marketPriceMax: number; public marketPriceMax: number;
@ -231,7 +232,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dataProviderInfo, dataProviderInfo,
dividendInBaseCurrency, dividendInBaseCurrency,
stakeRewards, stakeRewards,
investment, investmentInBaseCurrencyWithCurrencyEffect,
marketPrice, marketPrice,
marketPriceMax, marketPriceMax,
marketPriceMin, marketPriceMin,
@ -291,13 +292,15 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
} }
); );
this.investment = investment; this.investmentInBaseCurrencyWithCurrencyEffect =
investmentInBaseCurrencyWithCurrencyEffect;
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.investment >= NUMERICAL_PRECISION_THRESHOLD this.investmentInBaseCurrencyWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD
) { ) {
this.investmentPrecision = 0; this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0;
} }
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
@ -467,10 +470,9 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOwnTag = hasPermission( this.hasPermissionToCreateOwnTag =
this.user.permissions, hasPermission(this.user.permissions, permissions.createOwnTag) &&
permissions.createOwnTag this.user?.settings?.isExperimentalFeatures;
);
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags?.map((tag) => {
@ -486,9 +488,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
} }
public onCloneActivity(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], { this.router.navigate(
queryParams: { activityId: aActivity.id, createDialog: true } internalRoutes.portfolio.subRoutes.activities.routerLink,
}); {
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close(); this.dialogRef.close();
} }
@ -528,9 +533,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
} }
public onUpdateActivity(aActivity: Activity) { public onUpdateActivity(aActivity: Activity) {
this.router.navigate(['/portfolio', 'activities'], { this.router.navigate(
queryParams: { activityId: aActivity.id, editDialog: true } internalRoutes.portfolio.subRoutes.activities.routerLink,
}); {
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close(); this.dialogRef.close();
} }

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

@ -146,9 +146,9 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="investmentPrecision" [precision]="investmentInBaseCurrencyWithCurrencyEffectPrecision"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="investment" [value]="investmentInBaseCurrencyWithCurrencyEffect"
>Investment</gf-value >Investment</gf-value
> >
</div> </div>
@ -392,9 +392,7 @@
</mat-tab-group> </mat-tab-group>
<gf-tags-selector <gf-tags-selector
[hasPermissionToCreateTag]=" [hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
hasPermissionToCreateOwnTag && user?.settings?.isExperimentalFeatures
"
[readonly]="!data.hasPermissionToUpdateOrder" [readonly]="!data.hasPermissionToUpdateOrder"
[tags]="activityForm.get('tags')?.value" [tags]="activityForm.get('tags')?.value"
[tagsAvailable]="tagsAvailable" [tagsAvailable]="tagsAvailable"

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

@ -9,6 +9,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types'; import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -38,6 +39,8 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
{ label: $localize`Active`, value: 'ACTIVE' }, { label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
public routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink;
public user: User; public user: User;
public viewModeFormControl = new FormControl<HoldingsViewMode>( public viewModeFormControl = new FormControl<HoldingsViewMode>(
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE

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

@ -59,7 +59,7 @@
class="mt-3" class="mt-3"
i18n i18n
mat-stroked-button mat-stroked-button
[routerLink]="['/portfolio', 'activities']" [routerLink]="routerLinkPortfolioActivities"
>Manage Activities</a >Manage Activities</a
> >
</div> </div>

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

Loading…
Cancel
Save