Browse Source

Merge branch 'ghostfolio:main' into translated-to-portugues

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

1
.env.dev

@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

2
.github/workflows/build-code.yml

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

2
.gitignore

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

2
.nvmrc

@ -1 +1 @@
v20
v22

139
CHANGELOG.md

@ -5,6 +5,127 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Upgraded `@keyv/redis` from version `4.3.4` to `4.4.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
@ -34,12 +155,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for Dutch (`nl`)
- 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 Spanish (`es`)
- Upgraded `countup.js` from version `2.8.0` to `2.8.2`
- Upgraded `nestjs` from version `10.4.15` to `11.0.12`
- Upgraded `prisma` from version `6.7.0` to `6.8.2`
@ -96,7 +217,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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`
### Fixed
@ -137,7 +258,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the error message of the currency code validation
- 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
- 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 `Nx` from version `20.8.0` to `20.8.1`
@ -237,7 +358,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- 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 Polish (`pl`)
- Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3`
@ -3692,7 +3813,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
- Added support for the thousand separator in the global heat map component
@ -3721,7 +3842,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 investment timeline grouped by year
- Set up the language localization for Français (`fr`)
- Set up the language localization for French (`fr`)
### Changed
@ -3830,7 +3951,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- 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 `prisma` from version `4.6.1` to `4.7.1`
@ -4059,7 +4180,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- 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
@ -4081,7 +4202,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
## 1.198.0 - 25.09.2022

2
DEVELOPMENT.md

@ -5,7 +5,7 @@
### Prerequisites
- [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)
- 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
WORKDIR /ghostfolio
@ -33,24 +33,25 @@ COPY ./nx.json nx.json
COPY ./replace.build.mjs replace.build.mjs
COPY ./tsconfig.base.json tsconfig.base.json
ENV NX_DAEMON=false
RUN npm run build:production
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# 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
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
# 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
RUN npm run database:generate-typings
# 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"
ENV NODE_ENV=production

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 { ApiService } from '@ghostfolio/api/services/api/api.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import {
@ -55,6 +56,7 @@ export class AdminController {
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -66,6 +68,13 @@ export class AdminController {
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)
@Post('gather')
@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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -24,6 +25,7 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
DemoModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,

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

@ -824,7 +824,7 @@ export class AdminService {
where,
select: {
_count: {
select: { Account: true, activities: true }
select: { accounts: true, activities: true }
},
Analytics: {
select: {
@ -871,7 +871,7 @@ export class AdminService {
id,
role,
subscription,
accountCount: _count.Account || 0,
accountCount: _count.accounts || 0,
activityCount: _count.activities || 0,
country: Analytics?.country,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,

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

@ -1,13 +1,12 @@
import { EventsModule } from '@ghostfolio/api/events/events.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
@ -78,6 +77,7 @@ import { UserModule } from './user/user.module';
CacheModule,
ConfigModule.forRoot(),
ConfigurationModule,
CronModule,
DataGatheringModule,
DataProviderModule,
EventEmitterModule.forRoot(),
@ -128,10 +128,8 @@ import { UserModule } from './user/user.module';
SubscriptionModule,
SymbolModule,
TagsModule,
TwitterBotModule,
UserModule,
WatchlistModule
],
providers: [CronService]
]
})
export class AppModule {}

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

@ -1,7 +1,11 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { HttpException, Injectable } from '@nestjs/common';
@ -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;
} else {
throw new HttpException(

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

@ -1,10 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
@ -53,10 +49,8 @@ export class AiController {
filters,
mode,
impersonationId: undefined,
languageCode:
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,
userCurrency:
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
languageCode: this.request.user.Settings.settings.language,
userCurrency: this.request.user.Settings.settings.baseCurrency,
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -32,6 +33,7 @@ import { AiService } from './ai.service';
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -35,6 +36,7 @@ import { BenchmarksService } from './benchmarks.service';
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,

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

@ -85,7 +85,7 @@ export class MarketDataController {
{ dataSource, symbol }
]);
if (!assetProfile) {
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
@ -103,7 +103,7 @@ export class MarketDataController {
);
const canUpsertOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -29,6 +30,7 @@ import { PublicController } from './public.controller';
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,

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

@ -1,9 +1,17 @@
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 { Export } from '@ghostfolio/common/interfaces';
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 { AuthGuard } from '@nestjs/passport';
@ -19,16 +27,21 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});

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

@ -1,5 +1,6 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.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 { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -9,8 +10,14 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [AccountModule, ApiModule, OrderModule, TagModule],
controllers: [ExportController],
imports: [
AccountModule,
ApiModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],
providers: [ExportService]
})
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 {
@IsArray()
@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({
isDryRun,
maxActivitiesToImport,
accountsDto: importData.accounts ?? [],
accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities,
user: this.request.user
});

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

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

@ -53,14 +53,19 @@ export class OrderController {
@Delete()
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});

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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -39,6 +40,7 @@ import { RulesService } from './rules.service';
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,

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

@ -23,6 +23,7 @@ import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/ru
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
@ -31,7 +32,7 @@ import {
} from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID,
TAG_ID_EMERGENCY_FUND,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
@ -105,6 +106,7 @@ export class PortfolioService {
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
@ -542,7 +544,7 @@ export class PortfolioService {
}
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = await this.getCashPositions({
const cashPositions = this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
@ -564,10 +566,10 @@ export class PortfolioService {
if (
filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID &&
filters[0].id === TAG_ID_EMERGENCY_FUND &&
filters[0].type === 'TAG'
) {
const emergencyFundCashPositions = await this.getCashPositions({
const emergencyFundCashPositions = this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
@ -663,7 +665,7 @@ export class PortfolioService {
grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined,
historicalData: [],
investment: undefined,
investmentInBaseCurrencyWithCurrencyEffect: undefined,
marketPrice: undefined,
marketPriceMax: undefined,
marketPriceMin: undefined,
@ -853,7 +855,8 @@ export class PortfolioService {
grossPerformanceWithCurrencyEffect:
position.grossPerformanceWithCurrencyEffect?.toNumber(),
historicalData: historicalDataArray,
investment: position.investment?.toNumber(),
investmentInBaseCurrencyWithCurrencyEffect:
position.investmentWithCurrencyEffect?.toNumber(),
netPerformance: position.netPerformance?.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
netPerformancePercentWithCurrencyEffect:
@ -952,7 +955,7 @@ export class PortfolioService {
grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined,
historicalData: historicalDataArray,
investment: 0,
investmentInBaseCurrencyWithCurrencyEffect: 0,
netPerformance: undefined,
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
@ -1318,6 +1321,8 @@ export class PortfolioService {
[
new EmergencyFundSetup(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
@ -1331,6 +1336,8 @@ export class PortfolioService {
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
)
@ -1532,7 +1539,7 @@ export class PortfolioService {
return { markets, marketsAdvanced };
}
private async getCashPositions({
private getCashPositions({
cashDetails,
userCurrency,
value
@ -1654,7 +1661,7 @@ export class PortfolioService {
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return (
tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID;
return id === TAG_ID_EMERGENCY_FUND;
}) ?? false
);
});

60
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 { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import Keyv from 'keyv';
import ms from 'ms';
@Injectable()
export class RedisCacheService {
private client: Keyv;
public constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
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');
});
}
@ -28,28 +39,13 @@ export class RedisCacheService {
const keys: string[] = [];
const prefix = aPrefix;
this.cache.stores[0].deserialize = (value) => {
try {
return JSON.parse(value);
} catch (error: any) {
if (error instanceof SyntaxError) {
Logger.debug(
`Failed to parse json, returning the value as String: ${value}`,
'RedisCacheService'
);
return value;
} else {
throw error;
try {
for await (const [key] of this.client.iterator({})) {
if ((prefix && key.startsWith(prefix)) || !prefix) {
keys.push(key);
}
}
};
for await (const [key] of this.cache.stores[0].iterator({})) {
if ((prefix && key.startsWith(prefix)) || !prefix) {
keys.push(key);
}
}
} catch {}
return keys;
}
@ -79,12 +75,22 @@ export class RedisCacheService {
}
public async isHealthy() {
const testKey = '__health_check__';
const testValue = Date.now().toString();
try {
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) =>
setTimeout(
() => reject(new Error('Redis health check timeout')),
() => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds')
)
)
@ -92,7 +98,13 @@ export class RedisCacheService {
return true;
} catch (error) {
Logger.error(error?.message, 'RedisCacheService');
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.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2024-09-30.acacia'
apiVersion: '2025-04-30.basil'
}
);
}
@ -61,7 +61,7 @@ export class SubscriptionService {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
user.Settings.settings.language
}/account`,
client_reference_id: user.id,
line_items: [

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

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

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

@ -52,11 +52,10 @@ import { sortBy, without } from 'lodash';
@Injectable()
export class UserService {
private i18nService = new I18nService();
public constructor(
private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@ -96,7 +95,7 @@ export class UserService {
}
public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
{ accounts, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const userData = await Promise.all([
@ -142,6 +141,7 @@ export class UserService {
}
return {
accounts,
activitiesCount,
id,
permissions,
@ -155,7 +155,6 @@ export class UserService {
permissions: accessItem.permissions
};
}),
accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: {
...(Settings.settings as UserSettings),
@ -182,7 +181,7 @@ export class UserService {
const {
Access,
accessToken,
Account,
accounts,
Analytics,
authChallenge,
createdAt,
@ -196,7 +195,7 @@ export class UserService {
} = await this.prismaService.user.findUnique({
include: {
Access: true,
Account: {
accounts: {
include: { Platform: true }
},
Analytics: true,
@ -209,7 +208,7 @@ export class UserService {
const user: UserWithSettings = {
Access,
accessToken,
Account,
accounts,
authChallenge,
createdAt,
id,
@ -298,10 +297,14 @@ export class UserService {
undefined
).getSettings(user.Settings.settings),
EmergencyFundSetup: new EmergencyFundSetup(
undefined,
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined,
undefined,
undefined
@ -411,6 +414,10 @@ export class UserService {
user.subscription.offer.durationExtension = undefined;
user.subscription.offer.label = undefined;
}
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount);
}
}
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);
}
user.Account = sortBy(user.Account, ({ name }) => {
user.accounts = sortBy(user.accounts, ({ name }) => {
return name.toLowerCase();
});
user.permissions = currentPermissions.sort();
@ -474,7 +481,7 @@ export class UserService {
const user = await this.prismaService.user.create({
data: {
...data,
Account: {
accounts: {
create: {
currency: DEFAULT_CURRENCY,
name: this.i18nService.getTranslation({

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

File diff suppressed because it is too large

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

@ -89,7 +89,7 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/oss-freunde</loc>
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
@ -317,7 +317,7 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
</url>
<url>
@ -395,7 +395,7 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/oss-amici</loc>
<loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
@ -454,10 +454,6 @@
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -470,6 +466,10 @@
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/wijzigingslogboek</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

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

@ -1,5 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
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 {
PortfolioPosition,
@ -14,28 +15,28 @@ import { RuleInterface } from './interfaces/rule.interface';
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private key: string;
private name: string;
private languageCode: string;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
{
key,
name
languageCode = DEFAULT_LANGUAGE_CODE
}: {
key: string;
name: string;
languageCode?: string; // TODO: Make mandatory
}
) {
this.key = key;
this.name = name;
this.languageCode = languageCode;
}
public getKey() {
return this.key;
}
public getName() {
return this.name;
public getLanguageCode() {
return this.languageCode;
}
public groupCurrentHoldingsByAttribute(
@ -73,5 +74,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
PortfolioReportRule['configuration']
>;
public abstract getName(): string;
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']
) {
super(exchangeRateDataService, {
key: AccountClusterRiskCurrentInvestment.name,
name: 'Investment'
key: AccountClusterRiskCurrentInvestment.name
});
this.accounts = accounts;
@ -88,6 +87,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
};
}
public getName() {
return 'Investment';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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']
) {
super(exchangeRateDataService, {
key: AccountClusterRiskSingleAccount.name,
name: 'Single Account'
key: AccountClusterRiskSingleAccount.name
});
this.accounts = accounts;
@ -38,6 +37,10 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
return undefined;
}
public getName() {
return 'Single Account';
}
public getSettings({ xRayRules }: UserSettings): RuleSettings {
return {
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[]
) {
super(exchangeRateDataService, {
key: AssetClassClusterRiskEquity.name,
name: 'Equity'
key: AssetClassClusterRiskEquity.name
});
this.holdings = holdings;
@ -78,6 +77,10 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
};
}
public getName() {
return 'Equity';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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[]
) {
super(exchangeRateDataService, {
key: AssetClassClusterRiskFixedIncome.name,
name: 'Fixed Income'
key: AssetClassClusterRiskFixedIncome.name
});
this.holdings = holdings;
@ -78,6 +77,10 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
};
}
public getName() {
return 'Fixed Income';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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[]
) {
super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
name: 'Investment: Base Currency'
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name
});
this.holdings = holdings;
@ -68,6 +67,10 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
return undefined;
}
public getName() {
return 'Investment: Base Currency';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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[]
) {
super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name,
name: 'Investment'
key: CurrencyClusterRiskCurrentInvestment.name
});
this.holdings = holdings;
@ -73,6 +72,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
};
}
public getName() {
return 'Investment';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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
) {
super(exchangeRateDataService, {
key: EconomicMarketClusterRiskDevelopedMarkets.name,
name: 'Developed Markets'
key: EconomicMarketClusterRiskDevelopedMarkets.name
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
};
}
public getName() {
return 'Developed Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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
) {
super(exchangeRateDataService, {
key: EconomicMarketClusterRiskEmergingMarkets.name,
name: 'Emerging Markets'
key: EconomicMarketClusterRiskEmergingMarkets.name
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
};
}
public getName() {
return 'Emerging Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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 { Rule } from '@ghostfolio/api/models/rule';
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';
export class EmergencyFundSetup extends Rule<Settings> {
@ -8,11 +9,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
emergencyFund: number
) {
super(exchangeRateDataService, {
key: EmergencyFundSetup.name,
name: 'Emergency Fund: Set up'
languageCode,
key: EmergencyFundSetup.name
});
this.emergencyFund = emergencyFund;
@ -21,13 +24,19 @@ export class EmergencyFundSetup extends Rule<Settings> {
public evaluate() {
if (!this.emergencyFund) {
return {
evaluation: 'No emergency fund has been set up',
evaluation: this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup.false',
languageCode: this.getLanguageCode()
}),
value: false
};
}
return {
evaluation: 'An emergency fund has been set up',
evaluation: this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup.true',
languageCode: this.getLanguageCode()
}),
value: true
};
}
@ -36,6 +45,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
return undefined;
}
public getName() {
return this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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 { Rule } from '@ghostfolio/api/models/rule';
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';
export class FeeRatioInitialInvestment extends Rule<Settings> {
@ -9,12 +10,14 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
totalInvestment: number,
fees: number
) {
super(exchangeRateDataService, {
key: FeeRatioInitialInvestment.name,
name: 'Fee Ratio'
languageCode,
key: FeeRatioInitialInvestment.name
});
this.fees = fees;
@ -28,17 +31,27 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
if (feeRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The fees do exceed ${
ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.false',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
thresholdMax: (feeRatio * 100).toPrecision(3)
}
}),
value: false
};
}
return {
evaluation: `The fees do not exceed ${
ruleSettings.thresholdMax * 100
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.true',
languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toFixed(2)
}
}),
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 {
return {
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
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskAsiaPacific.name,
name: 'Asia-Pacific'
key: RegionalMarketClusterRiskAsiaPacific.name
});
this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
};
}
public getName() {
return 'Asia-Pacific';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEmergingMarkets.name,
name: 'Emerging Markets'
key: RegionalMarketClusterRiskEmergingMarkets.name
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -68,6 +67,10 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
};
}
public getName() {
return 'Emerging Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEurope.name,
name: 'Europe'
key: RegionalMarketClusterRiskEurope.name
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
};
}
public getName() {
return 'Europe';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskJapan.name,
name: 'Japan'
key: RegionalMarketClusterRiskJapan.name
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
};
}
public getName() {
return 'Japan';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskNorthAmerica.name,
name: 'North America'
key: RegionalMarketClusterRiskNorthAmerica.name
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
};
}
public getName() {
return 'North America';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
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 { 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 {
DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
@ -10,12 +15,6 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
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()
export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
@ -43,7 +42,9 @@ export class CronService {
@Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePm() {
this.twitterBotService.tweetFearAndGreedIndex();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.twitterBotService.tweetFearAndGreedIndex();
}
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)

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 { Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
@Injectable()
export class I18nService {
private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
@ -15,10 +16,12 @@ export class I18nService {
public getTranslation({
id,
languageCode
languageCode,
placeholders
}: {
id: string;
languageCode: string;
placeholders?: Record<string, string | number>;
}): string {
const $ = this.translations[languageCode];
@ -26,7 +29,7 @@ export class I18nService {
Logger.warn(`Translation not found for locale '${languageCode}'`);
}
const translatedText = $(
let translatedText = $(
`trans-unit[id="${id}"] > ${
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();
}

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

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

7
apps/client-e2e/project.json

@ -3,12 +3,13 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"tags": [],
"implicitDependencies": ["client"],
"targets": {
"e2e": {
"executor": "@nx/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
@ -17,7 +18,5 @@
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
}
}

111
apps/client/project.json

@ -2,13 +2,63 @@
"name": "client",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/client/src",
"prefix": "gf",
"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": [],
"generators": {
"@schematics/angular:component": {
"style": "scss"
}
},
"sourceRoot": "apps/client/src",
"prefix": "gf",
"targets": {
"build": {
"executor": "@nx/angular:webpack-browser",
@ -135,7 +185,7 @@
"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"
@ -211,7 +261,8 @@
"production": {
"buildTarget": "client:build:production"
}
}
},
"continuous": true
},
"extract-i18n": {
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
@ -247,55 +298,5 @@
},
"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": []
}
}

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

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

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

@ -10,7 +10,7 @@
(click)="onCreateAccount()"
>
<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
>
}

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

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

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

@ -2,8 +2,12 @@ import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/ho
import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding-detail-dialog/interfaces/interfaces';
import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes';
import { ColorScheme } from '@ghostfolio/common/types';
import { DOCUMENT } from '@angular/common';
@ -63,25 +67,25 @@ export class AppComponent implements OnDestroy, OnInit {
public hasTabs = false;
public info: InfoItem;
public pageTitle: string;
public routerLinkAbout = ['/' + paths.about];
public routerLinkAboutChangelog = ['/' + paths.about, paths.changelog];
public routerLinkAboutLicense = ['/' + paths.about, paths.license];
public routerLinkAbout = ['/' + routes.about];
public routerLinkAboutChangelog = ['/' + routes.about, routes.changelog];
public routerLinkAboutLicense = ['/' + routes.about, routes.license];
public routerLinkAboutPrivacyPolicy = [
'/' + paths.about,
paths.privacyPolicy
'/' + routes.about,
routes.privacyPolicy
];
public routerLinkAboutTermsOfService = [
'/' + paths.about,
paths.termsOfService
'/' + routes.about,
routes.termsOfService
];
public routerLinkBlog = ['/' + paths.blog];
public routerLinkFaq = ['/' + paths.faq];
public routerLinkFeatures = ['/' + paths.features];
public routerLinkMarkets = ['/' + paths.markets];
public routerLinkOpenStartup = ['/' + paths.openStartup];
public routerLinkPricing = ['/' + paths.pricing];
public routerLinkRegister = ['/' + paths.register];
public routerLinkResources = ['/' + paths.resources];
public routerLinkBlog = ['/' + routes.blog];
public routerLinkFaq = ['/' + routes.faq];
public routerLinkFeatures = ['/' + routes.features];
public routerLinkMarkets = ['/' + routes.markets];
public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
public routerLinkPricing = ['/' + routes.pricing];
public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = ['/' + routes.resources];
public showFooter = false;
public user: User;
@ -160,12 +164,14 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentSubRoute = urlSegments[1]?.path;
if (
(this.currentRoute === 'home' && !this.currentSubRoute) ||
(this.currentRoute === 'home' &&
this.currentSubRoute === 'holdings') ||
(this.currentRoute === 'portfolio' && !this.currentSubRoute) ||
(this.currentRoute === 'zen' && !this.currentSubRoute) ||
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings')
((this.currentRoute === internalRoutes.home.path &&
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) &&
this.user?.settings?.viewMode !== 'ZEN'
) {
this.hasPermissionToChangeDateRange = true;
} else {
@ -173,14 +179,19 @@ export class AppComponent implements OnDestroy, OnInit {
}
if (
(this.currentRoute === 'home' &&
this.currentSubRoute === 'holdings') ||
(this.currentRoute === 'portfolio' && !this.currentSubRoute) ||
(this.currentRoute === 'portfolio' &&
this.currentSubRoute === 'activities') ||
(this.currentRoute === 'portfolio' &&
this.currentSubRoute === 'allocations') ||
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings')
(this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.activities.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute === routes.allocations) ||
(this.currentRoute === internalRoutes.zen.path &&
this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path)
) {
this.hasPermissionToChangeFilters = true;
} else {
@ -188,25 +199,25 @@ export class AppComponent implements OnDestroy, OnInit {
}
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'account' ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
this.currentRoute === 'portfolio' ||
this.currentRoute === 'zen') &&
(this.currentRoute === routes.about ||
this.currentRoute === routes.faq ||
this.currentRoute === routes.resources ||
this.currentRoute === routes.account ||
this.currentRoute === routes.adminControl ||
this.currentRoute === internalRoutes.home.path ||
this.currentRoute === internalRoutes.portfolio.path ||
this.currentRoute === internalRoutes.zen.path) &&
this.deviceType !== 'mobile';
this.showFooter =
(this.currentRoute === 'blog' ||
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
this.currentRoute === 'open' ||
this.currentRoute === 'p' ||
this.currentRoute === this.routerLinkPricing[0].slice(1) ||
this.currentRoute === this.routerLinkRegister[0].slice(1) ||
this.currentRoute === 'start') &&
(this.currentRoute === routes.blog ||
this.currentRoute === routes.features ||
this.currentRoute === routes.markets ||
this.currentRoute === publicRoutes.openStartup.path ||
this.currentRoute === routes.public ||
this.currentRoute === routes.pricing ||
this.currentRoute === publicRoutes.register.path ||
this.currentRoute === routes.start) &&
this.deviceType !== 'mobile';
if (this.deviceType === 'mobile') {

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

@ -1,8 +1,7 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
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 { paths } from '@ghostfolio/common/paths';
import { routes } from '@ghostfolio/common/routes';
import { Clipboard } from '@angular/cdk/clipboard';
import {
@ -54,9 +53,9 @@ export class AccessTableComponent implements OnChanges {
}
public getPublicUrl(aId: string): string {
const languageCode = this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
const languageCode = this.user.settings.language;
return `${this.baseUrl}/${languageCode}/${paths.public}/${aId}`;
return `${this.baseUrl}/${languageCode}/${routes.public}/${aId}`;
}
public onCopyUrlToClipboard(aId: string): void {

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

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

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

@ -44,10 +44,10 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.Platform?.url) {
<gf-asset-profile-icon
<gf-entity-logo
class="d-inline d-sm-none mr-1"
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
[url]="element.Platform.url"
/>
}
<span>{{ element.name }}</span>
@ -92,10 +92,10 @@
>
<div class="d-flex">
@if (element.Platform?.url) {
<gf-asset-profile-icon
<gf-entity-logo
class="mr-1"
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
[url]="element.Platform.url"
/>
}
<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 { CommonModule } from '@angular/common';
@ -17,7 +17,7 @@ import { AccountsTableComponent } from './accounts-table.component';
exports: [AccountsTableComponent],
imports: [
CommonModule,
GfAssetProfileIconComponent,
GfEntityLogoComponent,
GfValueComponent,
MatButtonModule,
MatMenuModule,

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

@ -507,7 +507,7 @@
<mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" />
@if (assetProfileForm.get('url').value) {
<gf-asset-profile-icon
<gf-entity-logo
class="mr-3"
matSuffix
[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 { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -27,8 +27,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
imports: [
CommonModule,
FormsModule,
GfAssetProfileIconComponent,
GfCurrencySelectorComponent,
GfEntityLogoComponent,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
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 { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
differenceInSeconds,
formatDistanceToNowStrict,
parseISO
} from 'date-fns';
import { StringValue } from 'ms';
import ms, { StringValue } from 'ms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public coupons: Coupon[];
public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean;
public hasPermissionToSyncDemoUserAccount: boolean;
public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem;
public isDataGatheringEnabled: boolean;
@ -60,6 +62,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
@ -80,6 +83,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
permissions.enableSystemMessage
);
this.hasPermissionToSyncDemoUserAccount = hasPermission(
this.user.permissions,
permissions.syncDemoUserAccount
);
this.hasPermissionToToggleReadOnlyMode = hasPermission(
this.user.permissions,
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() {
this.unsubscribeSubject.next();
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="w-50" i18n>Housekeeping</div>
<div class="w-50">
<button color="warn" mat-flat-button (click)="onFlushCache()">
<ion-icon class="mr-1" name="close-circle-outline" />
<span i18n>Flush Cache</span>
</button>
<div class="align-items-start d-flex flex-column">
@if (hasPermissionToSyncDemoUserAccount) {
<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>
</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 { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { AdminOverviewComponent } from './admin-overview.component';
@ -24,6 +25,7 @@ import { AdminOverviewComponent } from './admin-overview.component';
MatCardModule,
MatMenuModule,
MatSelectModule,
MatSnackBarModule,
MatSlideToggleModule,
ReactiveFormsModule,
RouterModule

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

@ -23,7 +23,7 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.url) {
<gf-asset-profile-icon
<gf-entity-logo
class="d-inline mr-1"
[tooltip]="element.name"
[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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -16,8 +16,8 @@ import { GfCreateOrUpdatePlatformDialogModule } from './create-or-update-platfor
exports: [AdminPlatformComponent],
imports: [
CommonModule,
GfAssetProfileIconComponent,
GfCreateOrUpdatePlatformDialogModule,
GfEntityLogoComponent,
MatButtonModule,
MatMenuModule,
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()"
/>
@if (data.platform.url) {
<gf-asset-profile-icon
class="mr-3"
matSuffix
[url]="data.platform.url"
/>
<gf-entity-logo class="mr-3" matSuffix [url]="data.platform.url" />
}
</mat-form-field>
</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 { NgModule } from '@angular/core';
@ -14,8 +14,8 @@ import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog
declarations: [CreateOrUpdatePlatformDialog],
imports: [
CommonModule,
GfAssetProfileIconComponent,
FormsModule,
GfEntityLogoComponent,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,

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

@ -9,7 +9,7 @@
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<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>
@if (isGhostfolioDataProvider(element)) {
<a
@ -23,7 +23,7 @@
[enableLink]="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
>
}
@ -105,12 +105,11 @@
</mat-menu>
} @else if (isGhostfolioApiKeyValid === false) {
<button
color="accent"
class="special"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API key</span>
<ng-container i18n>Set API key</ng-container>
</button>
}
}

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

@ -1,6 +1,19 @@
:host {
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 {
--mdc-linear-progress-active-indicator-height: 0.5rem;
--mdc-linear-progress-track-height: 0.5rem;

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

@ -3,17 +3,14 @@ import { NotificationService } from '@ghostfolio/client/core/notification/notifi
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
DataProviderInfo,
User
} from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { routes } from '@ghostfolio/common/routes';
import {
ChangeDetectionStrategy,
@ -70,13 +67,12 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user?.settings?.locale
this.user.settings.locale
);
const languageCode =
this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
const languageCode = this.user.settings.language;
this.pricingUrl = `https://ghostfol.io/${languageCode}/${paths.pricing}`;
this.pricingUrl = `https://ghostfol.io/${languageCode}/${routes.pricing}`;
this.changeDetectorRef.markForCheck();
}

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

@ -1,6 +1,6 @@
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.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 { CommonModule } from '@angular/common';
@ -20,7 +20,7 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
GfAssetProfileIconComponent,
GfEntityLogoComponent,
GfPremiumIndicatorComponent,
MatButtonModule,
MatMenuModule,

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

@ -12,8 +12,8 @@ import {
parseDate
} from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { routes } from '@ghostfolio/common/routes';
import { ColorScheme } from '@ghostfolio/common/types';
import {
@ -65,8 +65,8 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
public chart: Chart<'line'>;
public hasPermissionToAccessAdminControl: boolean;
public routerLinkAdminControlMarketData = [
'/' + paths.adminControl,
paths.marketData
'/' + routes.adminControl,
routes.marketData
];
public constructor() {

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

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

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

@ -7,7 +7,11 @@
.logo-container {
&.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) {
@ -56,7 +60,11 @@
.logo-container {
&.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)
);
}
}
}

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

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

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

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

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

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

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

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

6
apps/client/src/app/components/home-market/home-market.html

@ -37,8 +37,10 @@
[user]="user"
/>
@if (benchmarks?.length > 0) {
<div class="mt-3 text-center">
<small class="text-muted" i18n>
<div
class="gf-text-wrap-balance line-height-1 mt-3 text-center text-muted"
>
<small i18n>
Calculations are based on delayed market data and may not be
displayed in real-time.</small
>

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

@ -10,8 +10,8 @@ import {
PortfolioPerformance,
User
} from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -37,12 +37,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
public precision = 2;
public routerLinkAccounts = ['/' + paths.accounts];
public routerLinkPortfolio = ['/' + paths.portfolio];
public routerLinkPortfolioActivities = [
'/' + paths.portfolio,
paths.activities
];
public routerLinkAccounts = internalRoutes.accounts.routerLink;
public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink;
public showDetails = false;
public unit: string;
public user: User;

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

@ -1,4 +1,4 @@
import { paths } from '@ghostfolio/common/paths';
import { routes } from '@ghostfolio/common/routes';
import {
ChangeDetectionStrategy,
@ -28,7 +28,7 @@ export class SubscriptionInterstitialDialog implements OnInit {
public remainingSkipButtonDelay =
SubscriptionInterstitialDialog.SKIP_BUTTON_DELAY_IN_SECONDS;
public routerLinkPricing = ['/' + paths.pricing];
public routerLinkPricing = ['/' + routes.pricing];
public variantIndex: number;
private unsubscribeSubject = new Subject<void>();

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

@ -4,8 +4,8 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { routes } from '@ghostfolio/common/routes';
import {
ChangeDetectionStrategy,
@ -37,7 +37,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
public routerLinkPricing = ['/' + paths.pricing];
public routerLinkPricing = ['/' + routes.pricing];
public trySubscriptionMail =
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
public user: User;

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

@ -1,7 +1,11 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { paths } from '@ghostfolio/common/paths';
import {
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes';
import { Injectable } from '@angular/core';
import {
@ -15,17 +19,17 @@ import { catchError } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class AuthGuard {
private static PUBLIC_PAGE_ROUTES = [
`/${paths.about}`,
`/${paths.blog}`,
`/${paths.demo}`,
`/${paths.faq}`,
`/${paths.features}`,
`/${paths.markets}`,
`/${paths.openStartup}`,
`/${paths.pricing}`,
`/${paths.public}`,
`/${paths.register}`,
`/${paths.resources}`
`/${routes.about}`,
`/${routes.blog}`,
`/${routes.demo}`,
`/${routes.faq}`,
`/${routes.features}`,
`/${routes.markets}`,
`/${publicRoutes.openStartup.path}`,
`/${routes.pricing}`,
`/${routes.public}`,
`/${publicRoutes.register.path}`,
`/${routes.resources}`
];
public constructor(
@ -48,10 +52,10 @@ export class AuthGuard {
.pipe(
catchError(() => {
if (utmSource === 'ios') {
this.router.navigate(['/' + paths.demo]);
this.router.navigate(['/' + routes.demo]);
resolve(false);
} else if (utmSource === 'trusted-web-activity') {
this.router.navigate(['/' + paths.register]);
this.router.navigate(publicRoutes.register.routerLink);
resolve(false);
} else if (
AuthGuard.PUBLIC_PAGE_ROUTES.some((publicPageRoute) => {
@ -62,7 +66,7 @@ export class AuthGuard {
resolve(true);
return EMPTY;
} else if (state.url !== '/start') {
this.router.navigate(['/' + paths.start]);
this.router.navigate(['/' + routes.start]);
resolve(false);
return EMPTY;
}
@ -88,26 +92,26 @@ export class AuthGuard {
resolve(true);
return;
} else if (
state.url.startsWith(`/${paths.home}`) &&
state.url.startsWith(`/${internalRoutes.home.path}`) &&
user.settings.viewMode === 'ZEN'
) {
this.router.navigate(['/' + paths.zen]);
this.router.navigate(internalRoutes.zen.routerLink);
resolve(false);
return;
} else if (state.url.startsWith(`/${paths.start}`)) {
} else if (state.url.startsWith(`/${routes.start}`)) {
if (user.settings.viewMode === 'ZEN') {
this.router.navigate(['/' + paths.zen]);
this.router.navigate(internalRoutes.zen.routerLink);
} else {
this.router.navigate(['/' + paths.home]);
this.router.navigate(internalRoutes.home.routerLink);
}
resolve(false);
return;
} else if (
state.url.startsWith(`/${paths.zen}`) &&
state.url.startsWith(`/${internalRoutes.zen.path}`) &&
user.settings.viewMode === 'DEFAULT'
) {
this.router.navigate(['/' + paths.home]);
this.router.navigate(internalRoutes.home.routerLink);
resolve(false);
return;
}

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

@ -2,7 +2,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { routes } from '@ghostfolio/common/routes';
import {
HTTP_INTERCEPTORS,
@ -75,7 +75,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
});
this.snackBarRef.onAction().subscribe(() => {
this.router.navigate(['/' + paths.pricing]);
this.router.navigate(['/' + routes.pricing]);
});
}
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
@ -111,7 +111,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/' + paths.webauthn]);
this.router.navigate(['/' + routes.webauthn]);
} else {
this.tokenStorageService.signOut();
}

12
apps/client/src/app/pages/about/about-page-routing.module.ts

@ -1,5 +1,5 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { paths } from '@ghostfolio/common/paths';
import { routes as ghostfolioRoutes } from '@ghostfolio/common/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@ -18,35 +18,35 @@ const routes: Routes = [
)
},
{
path: paths.changelog,
path: ghostfolioRoutes.changelog,
loadChildren: () =>
import('./changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
},
{
path: paths.license,
path: ghostfolioRoutes.license,
loadChildren: () =>
import('./license/license-page.module').then(
(m) => m.LicensePageModule
)
},
{
path: paths.ossFriends,
path: ghostfolioRoutes.ossFriends,
loadChildren: () =>
import('./oss-friends/oss-friends-page.module').then(
(m) => m.OpenSourceSoftwareFriendsPageModule
)
},
{
path: paths.privacyPolicy,
path: ghostfolioRoutes.privacyPolicy,
loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
},
{
path: paths.termsOfService,
path: ghostfolioRoutes.termsOfService,
loadChildren: () =>
import('./terms-of-service/terms-of-service-page.module').then(
(m) => m.TermsOfServicePageModule

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

@ -1,8 +1,8 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { routes } from '@ghostfolio/common/routes';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -44,17 +44,17 @@ export class AboutPageComponent implements OnDestroy, OnInit {
{
iconName: 'information-circle-outline',
label: $localize`About`,
path: ['/' + paths.about]
path: ['/' + routes.about]
},
{
iconName: 'sparkles-outline',
label: $localize`Changelog`,
path: ['/' + paths.about, paths.changelog]
path: ['/' + routes.about, routes.changelog]
},
{
iconName: 'ribbon-outline',
label: $localize`License`,
path: ['/' + paths.about, paths.license],
path: ['/' + routes.about, routes.license],
showCondition: !this.hasPermissionForSubscription
}
];
@ -63,14 +63,14 @@ export class AboutPageComponent implements OnDestroy, OnInit {
this.tabs.push({
iconName: 'shield-checkmark-outline',
label: $localize`Privacy Policy`,
path: ['/' + paths.about, paths.privacyPolicy],
path: ['/' + routes.about, routes.privacyPolicy],
showCondition: this.hasPermissionForSubscription
});
this.tabs.push({
iconName: 'document-text-outline',
label: $localize`Terms of Service`,
path: ['/' + paths.about, paths.termsOfService],
path: ['/' + routes.about, routes.termsOfService],
showCondition: this.hasPermissionForSubscription
});
@ -82,7 +82,7 @@ export class AboutPageComponent implements OnDestroy, OnInit {
this.tabs.push({
iconName: 'happy-outline',
label: 'OSS Friends',
path: ['/' + paths.about, paths.ossFriends]
path: ['/' + routes.about, routes.ossFriends]
});
});
}

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

@ -1,8 +1,8 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes, routes } from '@ghostfolio/common/routes';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
@ -18,10 +18,10 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public routerLinkBlog = ['/' + paths.blog];
public routerLinkFaq = ['/' + paths.faq];
public routerLinkFeatures = ['/' + paths.features];
public routerLinkOpenStartup = ['/' + paths.openStartup];
public routerLinkBlog = ['/' + routes.blog];
public routerLinkFaq = ['/' + routes.faq];
public routerLinkFeatures = ['/' + routes.features];
public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
public user: User;
private unsubscribeSubject = new Subject<void>();

3
apps/client/src/app/pages/accounts/accounts-page-routing.module.ts

@ -1,4 +1,5 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { internalRoutes } from '@ghostfolio/common/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@ -10,7 +11,7 @@ const routes: Routes = [
canActivate: [AuthGuard],
component: AccountsPageComponent,
path: '',
title: $localize`Accounts`
title: internalRoutes.accounts.title
}
];

2
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -61,7 +61,7 @@
) {
<mat-option [value]="platformEntry">
<span class="d-flex">
<gf-asset-profile-icon
<gf-entity-logo
class="mr-1"
[tooltip]="platformEntry.name"
[url]="platformEntry.url"

4
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts

@ -1,5 +1,5 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
@ -18,8 +18,8 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
imports: [
CommonModule,
FormsModule,
GfAssetProfileIconComponent,
GfCurrencySelectorComponent,
GfEntityLogoComponent,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,

4
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html

@ -14,7 +14,7 @@
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.Platform?.url) {
<gf-asset-profile-icon
<gf-entity-logo
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
@ -35,7 +35,7 @@
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.Platform?.url) {
<gf-asset-profile-icon
<gf-entity-logo
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"

4
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-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 { NgModule } from '@angular/core';
@ -15,7 +15,7 @@ import { TransferBalanceDialog } from './transfer-balance-dialog.component';
declarations: [TransferBalanceDialog],
imports: [
CommonModule,
GfAssetProfileIconComponent,
GfEntityLogoComponent,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,

10
apps/client/src/app/pages/admin/admin-page-routing.module.ts

@ -4,7 +4,7 @@ import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-over
import { AdminSettingsComponent } from '@ghostfolio/client/components/admin-settings/admin-settings.component';
import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { paths } from '@ghostfolio/common/paths';
import { routes as ghostfolioRoutes } from '@ghostfolio/common/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@ -21,22 +21,22 @@ const routes: Routes = [
title: $localize`Admin Control`
},
{
path: paths.jobs,
path: ghostfolioRoutes.jobs,
component: AdminJobsComponent,
title: $localize`Job Queue`
},
{
path: paths.marketData,
path: ghostfolioRoutes.marketData,
component: AdminMarketDataComponent,
title: $localize`Market Data`
},
{
path: paths.settings,
path: ghostfolioRoutes.settings,
component: AdminSettingsComponent,
title: $localize`Settings`
},
{
path: paths.users,
path: ghostfolioRoutes.users,
component: AdminUsersComponent,
title: $localize`Users`
}

12
apps/client/src/app/pages/admin/admin-page.component.ts

@ -1,5 +1,5 @@
import { TabConfiguration } from '@ghostfolio/common/interfaces';
import { paths } from '@ghostfolio/common/paths';
import { routes } from '@ghostfolio/common/routes';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -27,27 +27,27 @@ export class AdminPageComponent implements OnDestroy, OnInit {
{
iconName: 'reader-outline',
label: $localize`Overview`,
path: ['/' + paths.adminControl]
path: ['/' + routes.adminControl]
},
{
iconName: 'settings-outline',
label: $localize`Settings`,
path: ['/' + paths.adminControl, paths.settings]
path: ['/' + routes.adminControl, routes.settings]
},
{
iconName: 'server-outline',
label: $localize`Market Data`,
path: ['/' + paths.adminControl, paths.marketData]
path: ['/' + routes.adminControl, routes.marketData]
},
{
iconName: 'flash-outline',
label: $localize`Job Queue`,
path: ['/' + paths.adminControl, paths.jobs]
path: ['/' + routes.adminControl, routes.jobs]
},
{
iconName: 'people-outline',
label: $localize`Users`,
path: ['/' + paths.adminControl, paths.users]
path: ['/' + routes.adminControl, routes.users]
}
];
}

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

Loading…
Cancel
Save