Browse Source

Merge branch 'main' into feature/include-cash-in-portfolio-performance

pull/7148/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
03a669bf82
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 89
      CHANGELOG.md
  2. 2
      README.md
  3. 8
      apps/api/src/app/access/access.controller.ts
  4. 2
      apps/api/src/app/account-balance/account-balance.service.ts
  5. 21
      apps/api/src/app/account/account.service.ts
  6. 22
      apps/api/src/app/activities/activities.service.ts
  7. 91
      apps/api/src/app/admin/admin.service.ts
  8. 26
      apps/api/src/app/app.module.ts
  9. 21
      apps/api/src/app/auth/auth.controller.ts
  10. 4
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  11. 9
      apps/api/src/app/endpoints/public/public.controller.ts
  12. 4
      apps/api/src/app/import/import.controller.ts
  13. 5
      apps/api/src/app/import/import.service.ts
  14. 2
      apps/api/src/app/info/info.module.ts
  15. 28
      apps/api/src/app/info/info.service.ts
  16. 21
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  17. 10
      apps/api/src/app/portfolio/portfolio.controller.ts
  18. 65
      apps/api/src/app/portfolio/portfolio.service.ts
  19. 4
      apps/api/src/app/subscription/subscription.service.ts
  20. 1
      apps/api/src/app/symbol/symbol.controller.ts
  21. 6
      apps/api/src/app/symbol/symbol.service.ts
  22. 7
      apps/api/src/app/user/user.service.ts
  23. 8
      apps/api/src/events/events.module.ts
  24. 53
      apps/api/src/events/portfolio-changed.listener.ts
  25. 4
      apps/api/src/guards/custom-throttler.guard.ts
  26. 8
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  27. 12
      apps/api/src/main.ts
  28. 8
      apps/api/src/middlewares/html-template.middleware.ts
  29. 37
      apps/api/src/middlewares/language-redirect.middleware.ts
  30. 2
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  31. 2
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  32. 2
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  33. 2
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  34. 2
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  35. 2
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  36. 2
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  37. 2
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  38. 2
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  39. 2
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  40. 2
      apps/api/src/models/rules/liquidity/buying-power.ts
  41. 2
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  42. 2
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  43. 2
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  44. 2
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  45. 2
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  46. 16
      apps/api/src/services/api/api.service.ts
  47. 21
      apps/api/src/services/benchmark/benchmark.service.ts
  48. 8
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  49. 10
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  50. 10
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  51. 41
      apps/api/src/services/data-provider/data-provider.service.ts
  52. 11
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  53. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  54. 68
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  55. 6
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  56. 17
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  57. 13
      apps/api/src/services/data-provider/manual/manual.service.ts
  58. 4
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  59. 8
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  60. 2
      apps/api/src/services/market-data/market-data.service.ts
  61. 14
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts
  62. 7
      apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts
  63. 46
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  64. 9
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  65. 2
      apps/api/src/services/tag/tag.service.ts
  66. 3
      apps/client/project.json
  67. 8
      apps/client/src/app/adapter/custom-date-adapter.ts
  68. 7
      apps/client/src/app/app.component.ts
  69. 47
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  70. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  71. 4
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  72. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  73. 36
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  74. 120
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  75. 13
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  76. 27
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  77. 2
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  78. 9
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  79. 56
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  80. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  81. 4
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  82. 47
      apps/client/src/app/components/home-market/home-market.component.ts
  83. 21
      apps/client/src/app/components/home-market/home-market.html
  84. 4
      apps/client/src/app/components/home-market/home-market.scss
  85. 4
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  86. 14
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  87. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  88. 6
      apps/client/src/app/pages/landing/landing-page.html
  89. 141
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  90. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  91. 29
      apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts
  92. 169
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  93. 1
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts
  94. 63
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  95. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  96. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  97. 7
      apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts
  98. 27
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  99. 8
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  100. 15
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

89
CHANGELOG.md

@ -10,6 +10,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Included cash in the performance calculation of the portfolio - Included cash in the performance calculation of the portfolio
- Upgraded `fuse.js` from version `7.3.0` to `7.5.0`
### Fixed
- Resolved an exception in the `POST api/v1/activities` endpoint when creating an activity with the update account balance option but without an account
## 3.33.0 - 2026-07-25
### Added
- Added the stack trace logging for `MaxListenersExceededWarning` occurrences
### Changed
- Moved the support to create custom tags from experimental to general availability
- Recomputed the portfolio snapshot calculation in the background on a portfolio change
- Improved the deduplication of the portfolio snapshot calculation jobs by considering the filters
- Refactored the deprecated animation providers (`provideAnimations()` and `provideNoopAnimations()`)
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
### Fixed
- Fixed an issue with the localization in the _FIRE_ page
- Improved the spacing in the testimonial section on the landing page
## 3.32.0 - 2026-07-22
### Changed
- Upgraded `chartjs-chart-treemap` from version `3.1.0` to `4.2.0`
### Fixed
- Skipped opening the holding detail dialog for cash positions on the allocations page, the analysis page and the portfolio holdings page
- Resolved an exception in the `GET api/v1/portfolio/holding/:dataSource/:symbol` endpoint for cash positions
- Improved the error handling in the access endpoints (`POST` and `PUT`) to return `400 Bad Request` when granting access to a non-existent user
## 3.31.0 - 2026-07-20
### Changed
- Removed the deprecated `SymbolProfile` field from the activity interface
- Refactored the language redirect of the root path from the static file serving configuration to a dedicated middleware
- Upgraded `yahoo-finance2` from version `3.15.4` to `4.0.0`
### Fixed
- Fixed the `RangeNotSatisfiableError` for requests with a `Range` header to the root path caused by the empty `index.html` placeholder
- Fixed the unresolved template literal in the page title while the app is loading from the service worker cache
## 3.30.0 - 2026-07-19
### Added
- Added support for converting an asset profile to the `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed
- Extended the `extractNumberFromString()` function to support negative values
- Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users
- Removed the deprecated `auth` endpoint of the login with _Security Token_ (`GET`)
- Simplified the `getHistorical()` function response in the data provider interface
- Upgraded `bull-board` from version `8.0.1` to `8.1.2`
## 3.29.0 - 2026-07-18
### Added
- Added support for the _Fear & Greed Index_ (market mood) via the `GHOSTFOLIO` data provider in self-hosted environments
- Added a _Storybook_ story for the copy-to-clipboard functionality in the value component
### Changed
- Improved the copy-to-clipboard functionality in the value component by providing a visual confirmation
- Improved the language localization for German (`de`)
- Upgraded `stripe` from version `22.2.3` to `22.3.2`
### Fixed
- Fixed an issue with the delete button in the tags selector component
## 3.28.0 - 2026-07-17
### Changed
- Migrated the clone, create and edit activity dialogs to dedicated routes
- Improved the language localization in the historical market data table of the admin control panel
- Improved the language localization in the tag management of the admin control panel
### Fixed ### Fixed

2
README.md

@ -190,8 +190,6 @@ Set the header for each request as follows:
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`) You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Health Check (experimental) ### Health Check (experimental)
#### Request #### Request

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

@ -78,7 +78,7 @@ export class AccessController {
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -87,7 +87,7 @@ export class AccessController {
} }
try { try {
return this.accessService.createAccess({ return await this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
@ -134,7 +134,7 @@ export class AccessController {
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -155,7 +155,7 @@ export class AccessController {
} }
try { try {
return this.accessService.updateAccess({ return await this.accessService.updateAccess({
data: { data: {
alias: data.alias, alias: data.alias,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId

2
apps/api/src/app/account-balance/account-balance.service.ts

@ -178,7 +178,7 @@ export class AccountBalanceService {
accountId: balance.account.id, accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value, balance.value,
balance.account.currency, balance.account.currency ?? userCurrency,
userCurrency userCurrency
) )
}; };

21
apps/api/src/app/account/account.service.ts

@ -37,11 +37,26 @@ export class AccountService {
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const [account] = await this.accounts({ const account = await this.prismaService.account.findUnique({
where: id_userId include: {
balances: {
orderBy: { date: 'desc' },
take: 1
}
},
where: { id_userId }
}); });
return account; if (!account) {
return null;
}
const { balances, ...accountData } = account;
return {
...accountData,
balance: balances[0]?.value ?? 0
};
} }
public async accountWithActivities( public async accountWithActivities(

22
apps/api/src/app/activities/activities.service.ts

@ -275,7 +275,7 @@ export class ActivitiesService {
include: { SymbolProfile: true } include: { SymbolProfile: true }
}); });
if (updateAccountBalance === true) { if (accountId && updateAccountBalance === true) {
let amount = new Big(data.unitPrice).mul(data.quantity); let amount = new Big(data.unitPrice).mul(data.quantity);
if (['BUY', 'FEE'].includes(data.type)) { if (['BUY', 'FEE'].includes(data.type)) {
@ -499,23 +499,6 @@ export class ActivitiesService {
id: balanceItem.id, id: balanceItem.id,
isDraft: false, isDraft: false,
quantity: 1, quantity: 1,
SymbolProfile: {
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: new Date(balanceItem.date),
currency: account.currency,
dataSource:
this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
id: account.currency,
isActive: true,
name: account.currency,
sectors: [],
symbol: account.currency,
updatedAt: new Date(balanceItem.date)
},
symbolProfileId: account.currency, symbolProfileId: account.currency,
type: ActivityType.BUY, type: ActivityType.BUY,
unitPrice: 1, unitPrice: 1,
@ -862,8 +845,7 @@ export class ActivitiesService {
feeInBaseCurrency, feeInBaseCurrency,
unitPriceInAssetProfileCurrency, unitPriceInAssetProfileCurrency,
value, value,
valueInBaseCurrency, valueInBaseCurrency
SymbolProfile: assetProfile
}; };
}) })
); );

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

@ -12,6 +12,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol getCurrencyFromSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -39,6 +40,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@ -240,16 +242,25 @@ export class AdminService {
url url
}: Prisma.SymbolProfileUpdateInput }: Prisma.SymbolProfileUpdateInput
) { ) {
const isConversionToManualDataSource =
newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL;
if (isConversionToManualDataSource && !newSymbol) {
newSymbol = randomUUID();
}
if ( if (
newSymbol &&
newDataSource && newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource) newSymbol &&
(newDataSource !== dataSource || newSymbol !== symbol)
) { ) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ const newAssetProfileIdentifier: AssetProfileIdentifier = {
{ dataSource: newDataSource as DataSource,
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string symbol: newSymbol as string
} };
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]); ]);
if (assetProfile) { if (assetProfile) {
@ -259,45 +270,79 @@ export class AdminService {
); );
} }
try { const operations: Prisma.PrismaPromise<unknown>[] = [
await Promise.all([
this.symbolProfileService.updateAssetProfileIdentifier( this.symbolProfileService.updateAssetProfileIdentifier(
{ {
dataSource, dataSource,
symbol symbol
}, },
{ newAssetProfileIdentifier
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
), ),
this.marketDataService.updateAssetProfileIdentifier( this.marketDataService.updateAssetProfileIdentifier(
{ {
dataSource, dataSource,
symbol symbol
}, },
{ newAssetProfileIdentifier
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
) )
]); ];
const [updatedAssetProfile] = if (isConversionToManualDataSource) {
await this.symbolProfileService.getSymbolProfiles([ const currentAssetProfile =
await this.prismaService.symbolProfile.findUnique({
include: { assetProfileOverrides: true },
where: { dataSource_symbol: { dataSource, symbol } }
});
if (!currentAssetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const currentAssetProfileWithOverrides = applyAssetProfileOverrides(
currentAssetProfile,
currentAssetProfile.assetProfileOverrides
);
operations.push(
// The overrides are applied on every read, so delete them and
// persist the merged values in the asset profile instead
this.symbolProfileService.deleteAssetProfileOverrides(
newAssetProfileIdentifier
),
this.symbolProfileService.updateSymbolProfile(
newAssetProfileIdentifier,
{ {
dataSource: DataSource[newDataSource.toString()], assetClass: currentAssetProfileWithOverrides.assetClass,
symbol: newSymbol as string assetSubClass: currentAssetProfileWithOverrides.assetSubClass,
countries:
currentAssetProfileWithOverrides.countries ?? undefined,
holdings: currentAssetProfileWithOverrides.holdings ?? undefined,
name: currentAssetProfileWithOverrides.name,
sectors: currentAssetProfileWithOverrides.sectors ?? undefined,
url: currentAssetProfileWithOverrides.url
}
)
);
} }
]);
return updatedAssetProfile; try {
await this.prismaService.$transaction(operations);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]);
return updatedAssetProfile;
} else { } else {
const assetProfileOverrides = { const assetProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,

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

@ -14,8 +14,6 @@ import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-g
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES,
THROTTLE_DEFAULT_LIMIT, THROTTLE_DEFAULT_LIMIT,
THROTTLE_DEFAULT_TTL THROTTLE_DEFAULT_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -143,29 +141,7 @@ import { UserModule } from './user/user.module';
'/api/*wildcard', '/api/*wildcard',
'/sitemap.xml' '/sitemap.xml'
], ],
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client')
serveStaticOptions: {
setHeaders: (res) => {
if (res.req?.path === '/') {
let languageCode = DEFAULT_LANGUAGE_CODE;
try {
const code = res.req.headers['accept-language']
.split(',')[0]
.split('-')[0];
if (
(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(code)
) {
languageCode = code;
}
} catch {}
res.set('Location', `/${languageCode}`);
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
}
}
}
}), }),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'), rootPath: join(__dirname, '..', 'client', '.well-known'),

21
apps/api/src/app/auth/auth.controller.ts

@ -14,7 +14,6 @@ import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Param,
Post, Post,
Req, Req,
Res, Res,
@ -36,26 +35,6 @@ export class AuthController {
private readonly webAuthService: WebAuthService private readonly webAuthService: WebAuthService
) {} ) {}
/**
* @deprecated
*/
@Get('anonymous/:accessToken')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLoginGet(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken =
await this.authService.validateAnonymousLogin(accessToken);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Post('anonymous') @Post('anonymous')
@UseGuards(CustomThrottlerGuard) @UseGuards(CustomThrottlerGuard)
public async accessTokenLogin( public async accessTokenLogin(

4
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -171,7 +171,7 @@ export class GhostfolioService {
try { try {
const promises: Promise<{ const promises: Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}>[] = []; }>[] = [];
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
@ -185,7 +185,7 @@ export class GhostfolioService {
to to
}) })
.then((historicalData) => { .then((historicalData) => {
result.historicalData = historicalData[symbol]; result.historicalData = historicalData;
return historicalData; return historicalData;
}) })

9
apps/api/src/app/endpoints/public/public.controller.ts

@ -65,7 +65,7 @@ export class PublicController {
}); });
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === SubscriptionType.Premium; hasDetails = user?.subscription?.type === SubscriptionType.Premium;
} }
const { filters } = (access.settings ?? {}) as AccessSettings; const { filters } = (access.settings ?? {}) as AccessSettings;
@ -98,7 +98,7 @@ export class PublicController {
sortDirection: 'desc', sortDirection: 'desc',
take: 10, take: 10,
types: [ActivityType.BUY, ActivityType.SELL], types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, userCurrency: user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id, userId: user.id,
withExcludedAccountsAndActivities: false withExcludedAccountsAndActivities: false
}); });
@ -129,8 +129,7 @@ export class PublicController {
type, type,
unitPrice, unitPrice,
value, value,
valueInBaseCurrency, valueInBaseCurrency
SymbolProfile: assetProfile
}; };
} }
); );
@ -168,7 +167,7 @@ export class PublicController {
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
quantity * marketPrice, quantity * marketPrice,
assetProfile.currency, assetProfile.currency,
user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
) )
); );
}) })

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

@ -65,7 +65,7 @@ export class ImportController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium this.request.user.subscription?.type === SubscriptionType.Premium
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
@ -109,7 +109,7 @@ export class ImportController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Premium this.request.user.subscription?.type === SubscriptionType.Premium
) { ) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }

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

@ -153,7 +153,6 @@ export class ImportService {
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, unitPrice: marketPrice,
@ -623,9 +622,7 @@ export class ImportService {
assetProfile, assetProfile,
error, error,
value, value,
valueInBaseCurrency, valueInBaseCurrency
// @ts-ignore
SymbolProfile: assetProfile
}); });
} }

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

@ -7,6 +7,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -29,6 +30,7 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
MarketDataModule,
PlatformModule, PlatformModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,

28
apps/api/src/app/info/info.service.ts

@ -1,14 +1,15 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
ghostfolioFearAndGreedIndexSymbolStocks,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_DOCKER_HUB_PULLS, PROPERTY_DOCKER_HUB_PULLS,
@ -23,6 +24,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { MarketData } from '@prisma/client';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
@ -36,6 +38,7 @@ export class InfoService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly marketDataService: MarketDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -45,6 +48,7 @@ export class InfoService {
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean; let isReadOnlyMode: boolean;
let latestFearAndGreedStocksMarketDataPromise: Promise<MarketData>;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -61,16 +65,12 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
const fearAndGreedIndexDataSource = latestFearAndGreedStocksMarketDataPromise =
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(); this.marketDataService.getLatest({
dataSource:
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
info.fearAndGreedDataSource = encodeDataSource( symbol: ghostfolioFearAndGreedIndexSymbolStocks
fearAndGreedIndexDataSource });
);
} else {
info.fearAndGreedDataSource = fearAndGreedIndexDataSource;
}
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
@ -102,12 +102,14 @@ export class InfoService {
benchmarks, benchmarks,
demoAuthToken, demoAuthToken,
isUserSignupEnabled, isUserSignupEnabled,
latestFearAndGreedStocksMarketData,
statistics, statistics,
subscriptionOffer subscriptionOffer
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(), this.propertyService.isUserSignupEnabled(),
latestFearAndGreedStocksMarketDataPromise,
this.getStatistics(), this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' }) this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]); ]);
@ -125,7 +127,9 @@ export class InfoService {
statistics, statistics,
subscriptionOffer, subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies(),
fearAndGreedStocksMarketPrice:
latestFearAndGreedStocksMarketData?.marketPrice
}; };
} }

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

@ -50,6 +50,8 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isFuture,
isPast,
isWithinInterval, isWithinInterval,
min, min,
startOfDay, startOfDay,
@ -133,7 +135,7 @@ export abstract class PortfolioCalculator {
dateOfFirstActivity = date; dateOfFirstActivity = date;
} }
if (isAfter(date, new Date())) { if (isFuture(date)) {
// Adapt date to today if activity is in future (e.g. liability) // Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval // to include it in the interval
date = endOfDay(new Date()); date = endOfDay(new Date());
@ -1078,16 +1080,19 @@ export abstract class PortfolioCalculator {
let cachedPortfolioSnapshot: PortfolioSnapshot; let cachedPortfolioSnapshot: PortfolioSnapshot;
let isCachedPortfolioSnapshotExpired = false; let isCachedPortfolioSnapshotExpired = false;
const jobId = this.userId; const portfolioSnapshotKey = this.redisCacheService.getPortfolioSnapshotKey(
{
try {
const cachedPortfolioSnapshotValue = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
filters: this.filters, filters: this.filters,
userId: this.userId userId: this.userId
}) }
); );
const jobId = portfolioSnapshotKey;
try {
const cachedPortfolioSnapshotValue =
await this.redisCacheService.get(portfolioSnapshotKey);
const { expiration, portfolioSnapshot }: PortfolioSnapshotValue = const { expiration, portfolioSnapshot }: PortfolioSnapshotValue =
JSON.parse(cachedPortfolioSnapshotValue); JSON.parse(cachedPortfolioSnapshotValue);
@ -1096,7 +1101,7 @@ export abstract class PortfolioCalculator {
portfolioSnapshot portfolioSnapshot
); );
if (isAfter(new Date(), new Date(expiration))) { if (isPast(new Date(expiration))) {
isCachedPortfolioSnapshotExpired = true; isCachedPortfolioSnapshotExpired = true;
} }
} catch {} } catch {}

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

@ -97,7 +97,7 @@ export class PortfolioController {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = hasDetails =
this.request.user.subscription.type === SubscriptionType.Premium; this.request.user.subscription?.type === SubscriptionType.Premium;
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
@ -383,7 +383,7 @@ export class PortfolioController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
dividends = dividends.map((item) => { dividends = dividends.map((item) => {
return nullifyValuesInObject(item, ['investment']); return nullifyValuesInObject(item, ['investment']);
@ -511,7 +511,7 @@ export class PortfolioController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
investments = investments.map((item) => { investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']); return nullifyValuesInObject(item, ['investment']);
@ -623,7 +623,7 @@ export class PortfolioController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
performanceInformation.chart = performanceInformation.chart.map( performanceInformation.chart = performanceInformation.chart.map(
(item) => { (item) => {
@ -651,7 +651,7 @@ export class PortfolioController {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic this.request.user.subscription?.type === SubscriptionType.Basic
) { ) {
for (const category of report.xRay.categories) { for (const category of report.xRay.categories) {
category.rules = null; category.rules = null;

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

@ -777,10 +777,24 @@ export class PortfolioService {
return undefined; return undefined;
} }
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol } { dataSource, symbol }
]); ]);
const assetProfile =
symbolProfile ??
({
dataSource,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
currency: symbol,
holdings: [],
name: symbol,
sectors: []
} as EnhancedSymbolProfile);
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
userId, userId,
@ -823,9 +837,10 @@ export class PortfolioService {
timeWeightedInvestmentWithCurrencyEffect timeWeightedInvestmentWithCurrencyEffect
} = holding; } = holding;
const activitiesOfHolding = activities.filter(({ assetProfile }) => { const activitiesOfHolding = activities.filter((activity) => {
return ( return (
assetProfile.dataSource === dataSource && assetProfile.symbol === symbol activity.assetProfile.dataSource === dataSource &&
activity.assetProfile.symbol === symbol
); );
}); });
@ -857,19 +872,17 @@ export class PortfolioService {
new Date() new Date()
); );
const [firstActivity] = activitiesOfHolding;
const referenceUnitPrice =
firstActivity?.unitPriceInAssetProfileCurrency ?? marketPrice;
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let marketPriceMax = Math.max( let marketPriceMax = Math.max(referenceUnitPrice, marketPrice);
activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
let marketPriceMaxDate = let marketPriceMaxDate =
marketPrice > activitiesOfHolding[0].unitPriceInAssetProfileCurrency marketPrice > referenceUnitPrice
? new Date() ? new Date()
: activitiesOfHolding[0].date; : (firstActivity?.date ?? new Date());
let marketPriceMin = Math.min( let marketPriceMin = Math.min(referenceUnitPrice, marketPrice);
activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
marketPrice
);
const historicalDataItems = const historicalDataItems =
historicalData[getAssetProfileIdentifier({ dataSource, symbol })]; historicalData[getAssetProfileIdentifier({ dataSource, symbol })];
@ -920,10 +933,10 @@ export class PortfolioService {
} else { } else {
// Add historical entry for buy date, if no historical data available // Add historical entry for buy date, if no historical data available
historicalDataArray.push({ historicalDataArray.push({
averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, averagePrice: referenceUnitPrice,
date: dateOfFirstActivity, date: dateOfFirstActivity,
marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, marketPrice: referenceUnitPrice,
quantity: activitiesOfHolding[0].quantity quantity: firstActivity?.quantity ?? quantity.toNumber()
}); });
} }
@ -941,16 +954,16 @@ export class PortfolioService {
marketPriceMin, marketPriceMin,
tags, tags,
assetProfile: { assetProfile: {
assetClass: SymbolProfile.assetClass, assetClass: assetProfile.assetClass,
assetSubClass: SymbolProfile.assetSubClass, assetSubClass: assetProfile.assetSubClass,
countries: SymbolProfile.countries, countries: assetProfile.countries,
currency: SymbolProfile.currency, currency: assetProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: assetProfile.dataSource,
isin: SymbolProfile.isin, isin: assetProfile.isin,
name: SymbolProfile.name, name: assetProfile.name,
sectors: SymbolProfile.sectors, sectors: assetProfile.sectors,
symbol: SymbolProfile.symbol, symbol: assetProfile.symbol,
userId: SymbolProfile.userId userId: assetProfile.userId
}, },
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],

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

@ -38,7 +38,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2026-05-27.dahlia' apiVersion: '2026-06-24.dahlia'
} }
); );
} }
@ -149,7 +149,7 @@ export class SubscriptionService {
} }
const subscriptionOffer: SubscriptionOffer = JSON.parse( const subscriptionOffer: SubscriptionOffer = JSON.parse(
session.metadata.subscriptionOffer ?? '{}' session.metadata?.subscriptionOffer ?? '{}'
); );
const durationExtension = subscriptionOffer?.durationExtension; const durationExtension = subscriptionOffer?.durationExtension;

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

@ -65,6 +65,7 @@ export class SymbolController {
* Must be after /lookup * Must be after /lookup
*/ */
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData( public async getSymbolData(

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

@ -133,6 +133,12 @@ export class SymbolService {
}: { }: {
includeHistoricalData: number; includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> { }): Promise<MarketDataOfMarketsResponse> {
if (await this.dataProviderService.isDataProviderGhostfolioConfigured()) {
return this.dataProviderService.getMarketDataOfMarkets({
includeHistoricalData
});
}
const [ const [
marketDataFearAndGreedIndexCryptocurrencies, marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks marketDataFearAndGreedIndexStocks

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

@ -29,6 +29,7 @@ import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCALE, DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
@ -546,6 +547,12 @@ export class UserService {
if (hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount); currentPermissions.push(permissions.syncDemoUserAccount);
} }
} else {
if (
await this.propertyService.getByKey<string>(PROPERTY_API_KEY_GHOSTFOLIO)
) {
currentPermissions.push(permissions.readMarketDataOfMarkets);
}
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {

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

@ -1,9 +1,12 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -13,11 +16,14 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ApiModule,
ConfigurationModule, ConfigurationModule,
DataGatheringQueueModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
RedisCacheModule PortfolioSnapshotQueueModule,
RedisCacheModule,
UserModule
], ],
providers: [AssetProfileChangedListener, PortfolioChangedListener] providers: [AssetProfileChangedListener, PortfolioChangedListener]
}) })

53
apps/api/src/events/portfolio-changed.listener.ts

@ -1,4 +1,12 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
@ -14,7 +22,12 @@ export class PortfolioChangedListener {
private debounceTimers = new Map<string, NodeJS.Timeout>(); private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(
private readonly apiService: ApiService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly userService: UserService
) {}
@OnEvent(PortfolioChangedEvent.getName()) @OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) { handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
@ -39,6 +52,44 @@ export class PortfolioChangedListener {
private async processPortfolioChanged({ userId }: { userId: string }) { private async processPortfolioChanged({ userId }: { userId: string }) {
this.logger.log(`Portfolio of user '${userId}' has changed`); this.logger.log(`Portfolio of user '${userId}' has changed`);
try {
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
const user = await this.userService.user({ id: userId });
if (!user) {
return;
}
const userSettings = user.settings.settings;
const filters = this.apiService.buildFiltersFromUserSettings({
userSettings
});
// Recompute in the background to avoid a cold start on the next request
await this.portfolioSnapshotService.addJobToQueue({
data: {
filters,
userId,
calculationType: userSettings.performanceCalculationType,
userCurrency: userSettings.baseCurrency
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId: this.redisCacheService.getPortfolioSnapshotKey({
filters,
userId
}),
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
}
});
} catch (error) {
this.logger.error(
`Portfolio snapshot of user '${userId}' could not be recomputed`,
error
);
}
} }
} }

4
apps/api/src/guards/custom-throttler.guard.ts

@ -5,7 +5,9 @@ import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler';
export class CustomThrottlerGuard extends ThrottlerGuard { export class CustomThrottlerGuard extends ThrottlerGuard {
private readonly logger = new Logger(CustomThrottlerGuard.name); private readonly logger = new Logger(CustomThrottlerGuard.name);
public async canActivate(context: ExecutionContext): Promise<boolean> { public override async canActivate(
context: ExecutionContext
): Promise<boolean> {
try { try {
return await super.canActivate(context); return await super.canActivate(context);
} catch (error) { } catch (error) {

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

@ -80,10 +80,6 @@ export class TransformDataSourceInResponseInterceptor<
paths: [ paths: [
'activities[*].assetProfile.dataSource', 'activities[*].assetProfile.dataSource',
'activities[*].dataSource', 'activities[*].dataSource',
/* @deprecated */
'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource', 'assetProfile.dataSource',
'benchmarks[*].dataSource', 'benchmarks[*].dataSource',
'errors[*].dataSource', 'errors[*].dataSource',
@ -92,10 +88,6 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource', 'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource', 'holdings[*].dataSource',
'items[*].dataSource', 'items[*].dataSource',
/* @deprecated */
'SymbolProfile.dataSource',
'watchlist[*].dataSource' 'watchlist[*].dataSource'
] ]
}); });

12
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
@ -25,6 +26,15 @@ import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
const processWarningLogger = new Logger('ProcessWarning');
process.on('warning', ({ name, stack }) => {
if (name === 'MaxListenersExceededWarning') {
// Log the stack trace of MaxListenersExceededWarning occurrences to identify
// the event emitter and the call site which registers the listeners
processWarningLogger.warn(stack);
}
});
async function bootstrap() { async function bootstrap() {
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests // Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests
@ -100,6 +110,8 @@ async function bootstrap() {
}); });
} }
app.use(languageRedirectMiddleware);
const configurationService = app.get(ConfigurationService); const configurationService = app.get(ConfigurationService);
const trustProxy = configurationService.get('TRUST_PROXY'); const trustProxy = configurationService.get('TRUST_PROXY');

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

@ -111,7 +111,13 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
); );
try { try {
map[languageCode] = readFileSync(indexHtmlPath, 'utf8'); // Restore the interpolation token which the template replaces with a
// static fallback title to avoid showing an unresolved template
// literal when served without interpolation (e.g. by the service worker)
map[languageCode] = readFileSync(indexHtmlPath, 'utf8').replace(
/<title>.*?<\/title>/,
'<title>${title}</title>'
);
} catch { } catch {
this.logger.warn( this.logger.warn(
`Skipping language '${languageCode}': ${indexHtmlPath} not found` `Skipping language '${languageCode}': ${indexHtmlPath} not found`

37
apps/api/src/middlewares/language-redirect.middleware.ts

@ -0,0 +1,37 @@
import { environment } from '@ghostfolio/api/environments/environment';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { NextFunction, Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
export function languageRedirectMiddleware(
request: Request,
response: Response,
next: NextFunction
) {
if (
!environment.production ||
request.path !== '/' ||
!['GET', 'HEAD'].includes(request.method)
) {
return next();
}
let languageCode = DEFAULT_LANGUAGE_CODE;
try {
const code = request.headers['accept-language'].split(',')[0].split('-')[0];
if ((SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(code)) {
languageCode = code;
}
} catch {}
return response.redirect(
StatusCodes.MOVED_PERMANENTLY,
`/${languageCode}${request.url.slice(1)}`
);
}

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

@ -13,7 +13,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts']; private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']

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

@ -11,7 +11,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
private accounts: PortfolioDetails['accounts']; private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']

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

@ -11,7 +11,7 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
holdings: PortfolioPosition[] holdings: PortfolioPosition[]

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

@ -11,7 +11,7 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
holdings: PortfolioPosition[] holdings: PortfolioPosition[]

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

@ -11,7 +11,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
holdings: PortfolioPosition[], holdings: PortfolioPosition[],
languageCode: string languageCode: string

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

@ -11,7 +11,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private holdings: PortfolioPosition[]; private holdings: PortfolioPosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
holdings: PortfolioPosition[], holdings: PortfolioPosition[],
languageCode: string languageCode: string

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

@ -8,7 +8,7 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
private developedMarketsValueInBaseCurrency: number; private developedMarketsValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,
developedMarketsValueInBaseCurrency: number, developedMarketsValueInBaseCurrency: number,

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

@ -8,7 +8,7 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private emergingMarketsValueInBaseCurrency: number; private emergingMarketsValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,
emergingMarketsValueInBaseCurrency: number, emergingMarketsValueInBaseCurrency: number,

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

@ -7,7 +7,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
private emergencyFund: number; private emergencyFund: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
emergencyFund: number emergencyFund: number

2
apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts

@ -8,7 +8,7 @@ export class FeeRatioTotalInvestmentVolume extends Rule<Settings> {
private totalInvestmentVolumeInBaseCurrency: number; private totalInvestmentVolumeInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
totalInvestmentVolumeInBaseCurrency: number, totalInvestmentVolumeInBaseCurrency: number,

2
apps/api/src/models/rules/liquidity/buying-power.ts

@ -7,7 +7,7 @@ export class BuyingPower extends Rule<Settings> {
private buyingPower: number; private buyingPower: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
buyingPower: number, buyingPower: number,
languageCode: string languageCode: string

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

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
private currentValueInBaseCurrency: number; private currentValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

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

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private emergingMarketsValueInBaseCurrency: number; private emergingMarketsValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

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

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
private europeValueInBaseCurrency: number; private europeValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

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

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
private japanValueInBaseCurrency: number; private japanValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

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

@ -10,7 +10,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
private northAmericaValueInBaseCurrency: number; private northAmericaValueInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
currentValueInBaseCurrency: number, currentValueInBaseCurrency: number,

16
apps/api/src/services/api/api.service.ts

@ -1,4 +1,4 @@
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter, UserSettings } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -89,4 +89,18 @@ export class ApiService {
return filters; return filters;
} }
public buildFiltersFromUserSettings({
userSettings
}: {
userSettings: UserSettings;
}): Filter[] {
return this.buildFiltersFromQueryParams({
filterByAccounts: userSettings?.['filters.accounts']?.[0],
filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0],
filterByDataSource: userSettings?.['filters.dataSource'],
filterBySymbol: userSettings?.['filters.symbol'],
filterByTags: userSettings?.['filters.tags']?.[0]
});
}
} }

21
apps/api/src/services/benchmark/benchmark.service.ts

@ -18,12 +18,11 @@ import {
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { addHours, isAfter, subDays } from 'date-fns'; import { addHours, isPast, subDays } from 'date-fns';
import { round, uniqBy } from 'lodash'; import { round, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -94,7 +93,7 @@ export class BenchmarkService {
this.logger.debug('Fetched benchmarks from cache'); this.logger.debug('Fetched benchmarks from cache');
if (isAfter(new Date(), new Date(expiration))) { if (isPast(new Date(expiration))) {
this.calculateAndCacheBenchmarks({ this.calculateAndCacheBenchmarks({
enableSharing enableSharing
}); });
@ -146,7 +145,7 @@ export class BenchmarkService {
public async addBenchmark({ public async addBenchmark({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | undefined> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -183,7 +182,7 @@ export class BenchmarkService {
public async deleteBenchmark({ public async deleteBenchmark({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | null> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -240,12 +239,12 @@ export class BenchmarkService {
enableSharing enableSharing
}); });
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] = const promisesAllTimeHighs: ReturnType<
[]; typeof this.marketDataService.getMax
const promisesBenchmarkTrends: Promise<{ >[] = [];
trend50d: BenchmarkTrend; const promisesBenchmarkTrends: ReturnType<
trend200d: BenchmarkTrend; typeof this.getBenchmarkTrends
}>[] = []; >[] = [];
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {

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

@ -70,7 +70,7 @@ export class AlphaVantageService
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const historicalData: { const historicalData: {
@ -83,11 +83,9 @@ export class AlphaVantageService
); );
const response: { const response: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
response[symbol] = {};
for (const [key, timeSeries] of Object.entries( for (const [key, timeSeries] of Object.entries(
historicalData['Time Series (Digital Currency Daily)'] historicalData['Time Series (Digital Currency Daily)']
).sort()) { ).sort()) {
@ -95,7 +93,7 @@ export class AlphaVantageService
isAfter(from, parse(key, DATE_FORMAT, new Date())) && isAfter(from, parse(key, DATE_FORMAT, new Date())) &&
isBefore(to, parse(key, DATE_FORMAT, new Date())) isBefore(to, parse(key, DATE_FORMAT, new Date()))
) { ) {
response[symbol][key] = { response[key] = {
marketPrice: parseFloat(timeSeries['4a. close (USD)']) marketPrice: parseFloat(timeSeries['4a. close (USD)'])
}; };
} }

10
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -115,7 +115,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
@ -143,13 +143,11 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
} }
const result: { const result: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = { } = {};
[symbol]: {}
};
for (const [timestamp, marketPrice] of prices) { for (const [timestamp, marketPrice] of prices) {
result[symbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = { result[format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
marketPrice marketPrice
}; };
} }

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

@ -200,13 +200,13 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.assetClass = assetClass; response.assetClass = assetClass;
response.assetSubClass = assetSubClass; response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency; response.currency = assetProfile.price?.currency;
response.dataSource = this.getName(); response.dataSource = this.getName();
response.name = this.formatName({ response.name = this.formatName({
longName: assetProfile.price.longName, longName: assetProfile.price?.longName,
quoteType: assetProfile.price.quoteType, quoteType: assetProfile.price?.quoteType,
shortName: assetProfile.price.shortName, shortName: assetProfile.price?.shortName,
symbol: assetProfile.price.symbol symbol: assetProfile.price?.symbol
}); });
response.symbol = this.convertFromYahooFinanceSymbol( response.symbol = this.convertFromYahooFinanceSymbol(
assetProfile.price.symbol assetProfile.price.symbol

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

@ -27,7 +27,8 @@ import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderResponse, DataProviderResponse,
LookupItem, LookupItem,
LookupResponse LookupResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
@ -98,7 +99,7 @@ export class DataProviderService implements OnModuleInit {
return dataSource; return dataSource;
}); });
const promises = []; const promises: Promise<void>[] = [];
for (const [dataSource, assetProfileIdentifiers] of Object.entries( for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource itemsGroupedByDataSource
@ -194,11 +195,7 @@ export class DataProviderService implements OnModuleInit {
return DataSource[dataSource]; return DataSource[dataSource];
}); });
const ghostfolioApiKey = await this.propertyService.getByKey<string>( if (await this.isDataProviderGhostfolioConfigured()) {
PROPERTY_API_KEY_GHOSTFOLIO
);
if (ghostfolioApiKey) {
dataSources.push('GHOSTFOLIO'); dataSources.push('GHOSTFOLIO');
} }
@ -251,7 +248,7 @@ export class DataProviderService implements OnModuleInit {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === SubscriptionType.Basic user.subscription?.type === SubscriptionType.Basic
) { ) {
const dataProvider = this.getDataProvider(DataSource[dataSource]); const dataProvider = this.getDataProvider(DataSource[dataSource]);
@ -511,7 +508,7 @@ export class DataProviderService implements OnModuleInit {
requestTimeout: ms('30 seconds') requestTimeout: ms('30 seconds')
}) })
.then((data) => { .then((data) => {
return { dataSource, symbol, data: data?.[symbol] }; return { data, dataSource, symbol };
}) })
); );
} }
@ -551,6 +548,22 @@ export class DataProviderService implements OnModuleInit {
return result; return result;
} }
public async getMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> {
const dataProvider = this.getDataProvider(DataSource.GHOSTFOLIO);
if (!dataProvider.getMarketDataOfMarkets) {
throw new Error(
`The data provider (${DataSource.GHOSTFOLIO}) does not support the market data of markets`
);
}
return dataProvider.getMarketDataOfMarkets({ includeHistoricalData });
}
public async getQuotes({ public async getQuotes({
items, items,
requestTimeout, requestTimeout,
@ -647,7 +660,7 @@ export class DataProviderService implements OnModuleInit {
} else if ( } else if (
dataProvider.getDataProviderInfo().isPremium && dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription.type === SubscriptionType.Basic user?.subscription?.type === SubscriptionType.Basic
) { ) {
// Skip symbols of Premium data providers for users without subscription // Skip symbols of Premium data providers for users without subscription
return false; return false;
@ -807,6 +820,12 @@ export class DataProviderService implements OnModuleInit {
return response; return response;
} }
public async isDataProviderGhostfolioConfigured(): Promise<boolean> {
return !!(await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO
));
}
public async search({ public async search({
includeIndices = false, includeIndices = false,
query, query,
@ -857,7 +876,7 @@ export class DataProviderService implements OnModuleInit {
}) })
.map((lookupItem) => { .map((lookupItem) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (user.subscription.type === SubscriptionType.Premium) { if (user.subscription?.type === SubscriptionType.Premium) {
lookupItem.dataProviderInfo.isPremium = false; lookupItem.dataProviderInfo.isPremium = false;
} }

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

@ -147,7 +147,7 @@ export class EodHistoricalDataService
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
symbol = this.convertToEodSymbol(symbol); symbol = this.convertToEodSymbol(symbol);
@ -166,10 +166,9 @@ export class EodHistoricalDataService
}) })
.then((res) => res.json()); .then((res) => res.json());
return response.reduce( return response.reduce((result, { adjusted_close, date }) => {
(result, { adjusted_close, date }) => {
if (isNumber(adjusted_close)) { if (isNumber(adjusted_close)) {
result[this.convertFromEodSymbol(symbol)][date] = { result[date] = {
marketPrice: adjusted_close marketPrice: adjusted_close
}; };
} else { } else {
@ -179,9 +178,7 @@ export class EodHistoricalDataService
} }
return result; return result;
}, }, {});
{ [this.convertFromEodSymbol(symbol)]: {} }
);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(

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

@ -336,14 +336,12 @@ export class FinancialModelingPrepService
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
const MAX_YEARS_PER_REQUEST = 5; const MAX_YEARS_PER_REQUEST = 5;
const result: { const result: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = { } = {};
[symbol]: {}
};
let currentFrom = from; let currentFrom = from;
@ -378,7 +376,7 @@ export class FinancialModelingPrepService
isAfter(parseDate(date), currentFrom)) && isAfter(parseDate(date), currentFrom)) &&
isBefore(parseDate(date), currentTo) isBefore(parseDate(date), currentTo)
) { ) {
result[symbol][date] = { result[date] = {
marketPrice: close marketPrice: close
}; };
} }

68
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -5,6 +5,7 @@ import {
GetAssetProfileParams, GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetMarketDataOfMarketsParams,
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -23,7 +24,9 @@ import {
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
LookupResponse, LookupResponse,
QuotesResponse MarketDataOfMarketsResponse,
QuotesResponse,
SymbolItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -169,7 +172,7 @@ export class GhostfolioService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
@ -195,9 +198,7 @@ export class GhostfolioService implements DataProviderInterface {
const { historicalData } = (await response.json()) as HistoricalResponse; const { historicalData } = (await response.json()) as HistoricalResponse;
return { return historicalData;
[symbol]: historicalData
};
} catch (error) { } catch (error) {
if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
error.name = 'RequestError'; error.name = 'RequestError';
@ -224,6 +225,63 @@ export class GhostfolioService implements DataProviderInterface {
} }
} }
public async getMarketDataOfMarkets({
includeHistoricalData = 0,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetMarketDataOfMarketsParams): Promise<MarketDataOfMarketsResponse> {
let marketDataOfMarkets: MarketDataOfMarketsResponse = {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {} as SymbolItem,
STOCKS: {} as SymbolItem
}
};
try {
const queryParams = new URLSearchParams({
includeHistoricalData: includeHistoricalData.toString()
});
const response = await this.fetchService.fetch(
`${this.URL}/v1/data-providers/ghostfolio/markets?${queryParams.toString()}`,
{
headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout)
}
);
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
marketDataOfMarkets =
(await response.json()) as MarketDataOfMarketsResponse;
} catch (error) {
let message = error;
if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the market data of markets was aborted because the request to the data provider took more than ${(
requestTimeout / 1000
).toFixed(3)} seconds`;
} else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (
[StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
error?.status
)
) {
message =
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
this.logger.error(message);
}
return marketDataOfMarkets;
}
public getMaxNumberOfSymbolsPerRequest() { public getMaxNumberOfSymbolsPerRequest() {
return 20; return 20;
} }

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

@ -60,7 +60,7 @@ export class GoogleSheetsService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const sheet = await this.getSheet({ const sheet = await this.getSheet({
@ -85,9 +85,7 @@ export class GoogleSheetsService implements DataProviderInterface {
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close }; historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
}); });
return { return historicalData;
[symbol]: historicalData
};
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(

17
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -2,7 +2,8 @@ import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
DataProviderResponse, DataProviderResponse,
LookupResponse LookupResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -34,8 +35,13 @@ export interface DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}>; // TODO: Return only one symbol }>;
getMarketDataOfMarkets?({
includeHistoricalData,
requestTimeout
}: GetMarketDataOfMarketsParams): Promise<MarketDataOfMarketsResponse>;
getMaxNumberOfSymbolsPerRequest?(): number; getMaxNumberOfSymbolsPerRequest?(): number;
@ -72,6 +78,11 @@ export interface GetHistoricalParams {
to: Date; to: Date;
} }
export interface GetMarketDataOfMarketsParams {
includeHistoricalData?: number;
requestTimeout?: number;
}
export interface GetQuotesParams { export interface GetQuotesParams {
requestTimeout?: number; requestTimeout?: number;
symbols: string[]; symbols: string[];

13
apps/api/src/services/data-provider/manual/manual.service.ts

@ -79,7 +79,7 @@ export class ManualService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
@ -90,14 +90,13 @@ export class ManualService implements DataProviderInterface {
if (defaultMarketPrice) { if (defaultMarketPrice) {
const historical: { const historical: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = { } = {};
[symbol]: {}
};
let date = from; let date = from;
while (isBefore(date, to)) { while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = { historical[format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice marketPrice: defaultMarketPrice
}; };
@ -115,11 +114,9 @@ export class ManualService implements DataProviderInterface {
}); });
return { return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value marketPrice: value
} }
}
}; };
} catch (error) { } catch (error) {
throw new Error( throw new Error(

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

@ -58,7 +58,7 @@ export class RapidApiService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
try { try {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
@ -66,11 +66,9 @@ export class RapidApiService implements DataProviderInterface {
if (fgi) { if (fgi) {
return { return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value marketPrice: fgi.previousClose.value
} }
}
}; };
} }
} }

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

@ -123,7 +123,7 @@ export class YahooFinanceService implements DataProviderInterface {
symbol, symbol,
to to
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
}> { }> {
if (isSameDay(from, to)) { if (isSameDay(from, to)) {
to = addDays(to, 1); to = addDays(to, 1);
@ -144,13 +144,11 @@ export class YahooFinanceService implements DataProviderInterface {
); );
const response: { const response: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
response[symbol] = {};
for (const historicalItem of historicalResult) { for (const historicalItem of historicalResult) {
response[symbol][format(historicalItem.date, DATE_FORMAT)] = { response[format(historicalItem.date, DATE_FORMAT)] = {
marketPrice: historicalItem.close marketPrice: historicalItem.close
}; };
} }

2
apps/api/src/services/market-data/market-data.service.ts

@ -204,7 +204,7 @@ export class MarketDataService {
); );
} }
public async updateAssetProfileIdentifier( public updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier, oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier newAssetProfileIdentifier: AssetProfileIdentifier
) { ) {

14
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts

@ -1,4 +1,4 @@
import { Job, JobOptions } from 'bull'; import type { Job, JobId, JobOptions } from 'bull';
import { setTimeout } from 'timers/promises'; import { setTimeout } from 'timers/promises';
import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
@ -10,8 +10,8 @@ export const PortfolioSnapshotServiceMock = {
data: PortfolioSnapshotQueueJob; data: PortfolioSnapshotQueueJob;
name: string; name: string;
opts?: JobOptions; opts?: JobOptions;
}): Promise<Job<any>> { }): Promise<Job> {
const mockJob: Partial<Job<any>> = { const mockJob: Partial<Job> = {
finished: async () => { finished: async () => {
await setTimeout(100); await setTimeout(100);
@ -21,12 +21,12 @@ export const PortfolioSnapshotServiceMock = {
this.jobsStore.set(opts?.jobId, mockJob); this.jobsStore.set(opts?.jobId, mockJob);
return Promise.resolve(mockJob as Job<any>); return Promise.resolve(mockJob as Job);
}, },
getJob(jobId: string): Promise<Job<any>> { getJob(jobId: JobId): Promise<Job> {
const job = this.jobsStore.get(jobId); const job = this.jobsStore.get(jobId);
return Promise.resolve(job as Job<any>); return Promise.resolve(job as Job);
}, },
jobsStore: new Map<string, Partial<Job<any>>>() jobsStore: new Map<JobId, Partial<Job>>()
}; };

7
apps/api/src/services/queues/statistics-gathering/interfaces/interfaces.ts

@ -0,0 +1,7 @@
export interface BetterStackUptimeSlaResponse {
data: {
attributes: {
availability: number;
};
};
}

46
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -25,6 +25,14 @@ import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { BetterStackUptimeSlaResponse } from './interfaces/interfaces';
const GATHER_STATISTICS_CONCURRENCY = parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
);
@Injectable() @Injectable()
@Processor(STATISTICS_GATHERING_QUEUE) @Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor { export class StatisticsGatheringProcessor {
@ -37,11 +45,7 @@ export class StatisticsGatheringProcessor {
) {} ) {}
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME
}) })
public async gatherDockerHubPullsStatistics() { public async gatherDockerHubPullsStatistics() {
@ -58,11 +62,7 @@ export class StatisticsGatheringProcessor {
} }
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME
}) })
public async gatherGitHubContributorsStatistics() { public async gatherGitHubContributorsStatistics() {
@ -83,11 +83,7 @@ export class StatisticsGatheringProcessor {
} }
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME
}) })
public async gatherGitHubStargazersStatistics() { public async gatherGitHubStargazersStatistics() {
@ -106,11 +102,7 @@ export class StatisticsGatheringProcessor {
} }
@Process({ @Process({
concurrency: parseInt( concurrency: GATHER_STATISTICS_CONCURRENCY,
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME
}) })
public async gatherUptimeStatistics() { public async gatherUptimeStatistics() {
@ -140,14 +132,14 @@ export class StatisticsGatheringProcessor {
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const { pull_count } = (await this.fetchService const { pull_count } = await this.fetchService
.fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', { .fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
}) })
.then((res) => res.json())) as { pull_count: number }; .then<{ pull_count: number }>((res) => res.json());
return pull_count; return pull_count;
} catch (error) { } catch (error) {
@ -157,7 +149,7 @@ export class StatisticsGatheringProcessor {
} }
} }
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number | undefined> {
try { try {
const body = await this.fetchService const body = await this.fetchService
.fetch('https://github.com/ghostfolio/ghostfolio', { .fetch('https://github.com/ghostfolio/ghostfolio', {
@ -189,14 +181,14 @@ export class StatisticsGatheringProcessor {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const { stargazers_count } = (await this.fetchService const { stargazers_count } = await this.fetchService
.fetch('https://api.github.com/repos/ghostfolio/ghostfolio', { .fetch('https://api.github.com/repos/ghostfolio/ghostfolio', {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
}) })
.then((res) => res.json())) as { stargazers_count: number }; .then<{ stargazers_count: number }>((res) => res.json());
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
@ -213,7 +205,7 @@ export class StatisticsGatheringProcessor {
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to=${format(new Date(), DATE_FORMAT)}`,
{ {
headers: { headers: {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
@ -225,7 +217,7 @@ export class StatisticsGatheringProcessor {
) )
} }
) )
.then((res) => res.json()); .then<BetterStackUptimeSlaResponse>((res) => res.json());
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {

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

@ -35,6 +35,15 @@ export class SymbolProfileService {
}); });
} }
public deleteAssetProfileOverrides({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.prismaService.assetProfileOverrides.deleteMany({
where: { symbolProfile: { dataSource, symbol } }
});
}
public async deleteById(id: string) { public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({ return this.prismaService.symbolProfile.delete({
where: { id } where: { id }

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

@ -20,7 +20,7 @@ export class TagService {
public async getTag( public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> { ): Promise<Tag | null> {
return this.prismaService.tag.findUnique({ return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput where: tagWhereUniqueInput
}); });

3
apps/client/project.json

@ -203,9 +203,6 @@
{ {
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client" "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
}, },
{
"command": "shx cp apps/client/src/assets/index.html dist/apps/client"
},
{ {
"command": "shx cp apps/client/src/assets/robots.txt dist/apps/client" "command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
}, },

8
apps/client/src/app/adapter/custom-date-adapter.ts

@ -6,7 +6,7 @@ import { addYears, format, getYear, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
public constructor( public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string, @Inject(MAT_DATE_LOCALE) public override locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
) { ) {
super(matDateLocale); super(matDateLocale);
@ -15,21 +15,21 @@ export class CustomDateAdapter extends NativeDateAdapter {
/** /**
* Formats a date as a string * Formats a date as a string
*/ */
public format(aDate: Date): string { public override format(aDate: Date): string {
return format(aDate, getDateFormatString(this.locale)); return format(aDate, getDateFormatString(this.locale));
} }
/** /**
* Sets the first day of the week to Monday * Sets the first day of the week to Monday
*/ */
public getFirstDayOfWeek(): number { public override getFirstDayOfWeek(): number {
return 1; return 1;
} }
/** /**
* Parses a date from a provided value * Parses a date from a provided value
*/ */
public parse(aValue: string): Date { public override parse(aValue: string): Date {
let date = parse(aValue, getDateFormatString(this.locale), new Date()); let date = parse(aValue, getDateFormatString(this.locale), new Date());
if (getYear(date) < 1900) { if (getYear(date) < 1900) {

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

@ -40,6 +40,7 @@ import { filter } from 'rxjs/operators';
import { GfFooterComponent } from './components/footer/footer.component'; import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component'; import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogResult } from './components/holding-detail-dialog/interfaces/interfaces';
import { GfAppQueryParams } from './interfaces/interfaces'; import { GfAppQueryParams } from './interfaces/interfaces';
import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { UserService } from './services/user/user.service'; import { UserService } from './services/user/user.service';
@ -319,7 +320,11 @@ export class GfAppComponent implements OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe((result: HoldingDetailDialogResult) => {
if (result?.isNavigating) {
return;
}
void this.router.navigate([], { void this.router.navigate([], {
queryParams: { queryParams: {
dataSource: null, dataSource: null,

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

@ -15,7 +15,6 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -41,7 +40,7 @@ import { PageEvent } from '@angular/material/paginator';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router'; import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -53,9 +52,12 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin } from 'rxjs'; import { filter, forkJoin } from 'rxjs';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -113,11 +115,24 @@ export class GfAccountDetailDialogComponent implements OnInit {
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef = private readonly dialogRef =
inject<MatDialogRef<GfAccountDetailDialogComponent>>(MatDialogRef); inject<
MatDialogRef<GfAccountDetailDialogComponent, AccountDetailDialogResult>
>(MatDialogRef);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -155,17 +170,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities(); this.fetchActivities();
} }
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -208,17 +212,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchActivities(); this.fetchActivities();
} }
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView this.data.hasImpersonationId || this.user?.settings?.isRestrictedView

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

@ -132,8 +132,6 @@
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
[sortDirection]="sortDirection" [sortDirection]="sortDirection"
[totalItems]="totalItems" [totalItems]="totalItems"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()" (export)="onExport()"
(pageChanged)="onChangePage($event)" (pageChanged)="onChangePage($event)"
(sortChanged)="onSortChanged($event)" (sortChanged)="onSortChanged($event)"

4
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -4,3 +4,7 @@ export interface AccountDetailDialogParams {
hasImpersonationId: boolean; hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean; hasPermissionToCreateActivity: boolean;
} }
export interface AccountDetailDialogResult {
isNavigating?: boolean;
}

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

@ -13,7 +13,6 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -83,7 +82,6 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
GfActivitiesFilterComponent, GfActivitiesFilterComponent,
GfFabComponent, GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolPipe,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -158,6 +156,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
protected placeholder = ''; protected placeholder = '';
protected readonly selection = new SelectionModel<AssetProfileItem>(true); protected readonly selection = new SelectionModel<AssetProfileItem>(true);
protected totalItems = 0; protected totalItems = 0;
protected readonly translate = translate;
protected user: User; protected user: User;
private activeFilters: Filter[] = []; private activeFilters: Filter[] = [];
@ -409,7 +408,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfAssetProfileDialogComponent, GfAssetProfileDialogComponent,
AssetProfileDialogParams AssetProfileDialogParams,
AssetProfileIdentifier
>(GfAssetProfileDialogComponent, { >(GfAssetProfileDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
@ -427,15 +427,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe((newAssetProfileIdentifier) => {
(newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => {
if (newAssetProfileIdentifier) { if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier); this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else { } else {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
} });
);
}); });
} }

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

@ -87,13 +87,15 @@
> >
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell> <td
*matCellDef="let element"
class="line-height-normal px-1"
mat-cell
>
<div class="text-truncate">{{ element.name }}</div> <div class="text-truncate">{{ element.name }}</div>
@if (!isUUID(element.symbol)) { @if (!isUUID(element.symbol)) {
<div> <div>
<small class="text-muted">{{ <small class="text-muted">{{ element.symbol }}</small>
element.symbol | gfSymbol
}}</small>
</div> </div>
} }
</td> </td>
@ -113,8 +115,17 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Class</ng-container> <ng-container i18n>Asset Class</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td
{{ element.assetClass }} *matCellDef="let element"
class="line-height-normal px-1 text-nowrap"
mat-cell
>
<div>{{ translate(element.assetClass) }}</div>
@if (element.assetClass) {
<div>
<small class="text-muted">{{ element.assetClass }}</small>
</div>
}
</td> </td>
</ng-container> </ng-container>
@ -122,8 +133,17 @@
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Asset Sub Class</ng-container> <ng-container i18n>Asset Sub Class</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td
{{ element.assetSubClass }} *matCellDef="let element"
class="line-height-normal px-1 text-nowrap"
mat-cell
>
<div>{{ translate(element.assetSubClass) }}</div>
@if (element.assetSubClass) {
<div>
<small class="text-muted">{{ element.assetSubClass }}</small>
</div>
}
</td> </td>
</ng-container> </ng-container>

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

@ -5,6 +5,7 @@ import {
PROPERTY_IS_DATA_GATHERING_ENABLED PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
DATE_FORMAT, DATE_FORMAT,
@ -75,6 +76,7 @@ import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataGatheringFrequency, DataGatheringFrequency,
DataSource,
MarketData, MarketData,
Prisma, Prisma,
SymbolProfile SymbolProfile
@ -215,6 +217,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
]; ];
protected readonly DataSource = DataSource;
protected readonly dateRangeOptions = [ protected readonly dateRangeOptions = [
{ {
label: $localize`Current week` + ' (' + $localize`WTD` + ')', label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -277,7 +281,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
@Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private dialogRef: MatDialogRef<GfAssetProfileDialogComponent>, private dialogRef: MatDialogRef<
GfAssetProfileDialogComponent,
AssetProfileIdentifier
>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
@ -467,6 +474,19 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
protected onConvertToManualDataSource() {
this.patchAssetProfileIdentifier({
getErrorMessage: () => {
return (
'😞 ' +
$localize`An error occurred while converting the data source to ${DataSource.MANUAL}.`
);
},
title: $localize`Do you really want to convert the data source to ${DataSource.MANUAL}?`,
updateAssetProfileDto: { dataSource: DataSource.MANUAL }
});
}
protected onDeleteProfileData({ protected onDeleteProfileData({
dataSource, dataSource,
symbol symbol
@ -666,13 +686,16 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
protected async onSubmitAssetProfileIdentifierForm() { protected async onSubmitAssetProfileIdentifierForm() {
const newAssetProfileIdentifier =
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value;
if (!newAssetProfileIdentifier?.dataSource) {
return;
}
const assetProfileIdentifier: UpdateAssetProfileDto = { const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource: dataSource: newAssetProfileIdentifier.dataSource,
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value symbol: newAssetProfileIdentifier.symbol
?.dataSource ?? undefined,
symbol:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.symbol ?? undefined
}; };
try { try {
@ -687,45 +710,18 @@ export class GfAssetProfileDialogComponent implements OnInit {
return; return;
} }
this.adminService this.patchAssetProfileIdentifier({
.patchAssetProfile( getErrorMessage: (error) => {
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
assetProfileIdentifier
)
.pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.CONFLICT) { if (error.status === StatusCodes.CONFLICT) {
this.snackBar.open( // TODO: Ask if the user wants to merge the two asset profiles
$localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`,
undefined,
{
duration: ms('3 seconds')
}
);
} else {
this.snackBar.open(
$localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`,
undefined,
{
duration: ms('3 seconds')
}
);
}
return EMPTY; return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}), }
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const newAssetProfileIdentifier = {
dataSource: assetProfileIdentifier.dataSource,
symbol: assetProfileIdentifier.symbol
};
this.dialogRef.close(newAssetProfileIdentifier); return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`;
},
title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?`,
updateAssetProfileDto: assetProfileIdentifier
}); });
} }
@ -824,4 +820,42 @@ export class GfAssetProfileDialogComponent implements OnInit {
return null; return null;
} }
private patchAssetProfileIdentifier({
getErrorMessage,
title,
updateAssetProfileDto
}: {
getErrorMessage: (error: HttpErrorResponse) => string;
title: string;
updateAssetProfileDto: UpdateAssetProfileDto;
}) {
this.notificationService.confirm({
title,
confirmFn: () => {
this.adminService
.patchAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
updateAssetProfileDto
)
.pipe(
catchError((error: HttpErrorResponse) => {
this.snackBar.open(getErrorMessage(error), undefined, {
duration: ms('3 seconds')
});
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ dataSource, symbol }) => {
this.dialogRef.close({ dataSource, symbol });
});
},
confirmType: ConfirmationDialogType.Primary
});
}
} }

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

@ -162,6 +162,19 @@
<ng-container i18n>Cancel</ng-container> <ng-container i18n>Cancel</ng-container>
</button> </button>
</form> </form>
@if (data.dataSource !== DataSource.MANUAL) {
<p class="my-1 px-1 text-muted" i18n>or</p>
<button
color="accent"
mat-flat-button
type="button"
(click)="onConvertToManualDataSource()"
>
<ng-container i18n
>Convert to {{ DataSource.MANUAL }}</ng-container
>
</button>
}
</div> </div>
} @else { } @else {
<div class="col-6 mb-3"> <div class="col-6 mb-3">

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

@ -213,9 +213,16 @@ export class GfAdminOverviewComponent implements OnInit {
duration: this.couponDuration duration: this.couponDuration
}; };
const hasCopiedCouponCode = this.clipboard.copy(newCoupon.code);
const coupons = [...this.couponsDataSource.data, newCoupon]; const coupons = [...this.couponsDataSource.data, newCoupon];
this.saveCoupons({ coupons, codeToCopy: newCoupon.code }); this.saveCoupons({
coupons,
snackBarMessage: hasCopiedCouponCode
? '✅ ' + $localize`${newCoupon.code} has been copied to the clipboard`
: '✅ ' + $localize`Coupon ${newCoupon.code} has been created`
});
} }
protected onChangeCouponDuration(aCouponDuration: StringValue) { protected onChangeCouponDuration(aCouponDuration: StringValue) {
@ -374,11 +381,11 @@ export class GfAdminOverviewComponent implements OnInit {
} }
private saveCoupons({ private saveCoupons({
codeToCopy, coupons,
coupons snackBarMessage
}: { }: {
codeToCopy?: string;
coupons: Coupon[]; coupons: Coupon[];
snackBarMessage?: string;
}) { }) {
this.dataService this.dataService
.putAdminSetting(PROPERTY_COUPONS, { .putAdminSetting(PROPERTY_COUPONS, {
@ -388,14 +395,10 @@ export class GfAdminOverviewComponent implements OnInit {
.subscribe(() => { .subscribe(() => {
this.couponsDataSource.data = coupons; this.couponsDataSource.data = coupons;
if (codeToCopy) { if (snackBarMessage) {
this.clipboard.copy(codeToCopy); this.snackBar.open(snackBarMessage, undefined, {
duration: ms('3 seconds')
this.snackBar.open( });
'✅ ' + $localize`${codeToCopy} has been copied to the clipboard`,
undefined,
{ duration: ms('3 seconds') }
);
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

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

@ -22,7 +22,7 @@
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }} {{ translate(element.name) }}
</td> </td>
</ng-container> </ng-container>

9
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -3,6 +3,7 @@ import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -68,6 +69,7 @@ export class GfAdminTagComponent implements OnInit {
]; ];
protected readonly pageSize = DEFAULT_PAGE_SIZE; protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[]; protected tags: Tag[];
protected readonly translate = translate;
private readonly deviceType = computed( private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
@ -155,7 +157,12 @@ export class GfAdminTagComponent implements OnInit {
this.dataSource = new MatTableDataSource(this.tags); this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.paginator = this.paginator(); this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort(); this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase;
this.dataSource.sortingDataAccessor = (tag, path) => {
return path === 'name'
? translate(tag.name).toLocaleLowerCase()
: (getLowercase(tag, path) as number | string);
};
this.dataService.updateInfo(); this.dataService.updateInfo();

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

@ -63,7 +63,7 @@ import { PageEvent } from '@angular/material/paginator';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { Router, RouterModule } from '@angular/router'; import { NavigationStart, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Account, MarketData, Tag } from '@prisma/client'; import { Account, MarketData, Tag } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
@ -80,9 +80,12 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber, round, uniqBy } from 'lodash'; import { isNumber, round, uniqBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators'; import { filter, switchMap } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import {
HoldingDetailDialogParams,
HoldingDetailDialogResult
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -186,9 +189,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected value: number; protected value: number;
protected readonly data = inject<HoldingDetailDialogParams>(MAT_DIALOG_DATA); protected readonly data = inject<HoldingDetailDialogParams>(MAT_DIALOG_DATA);
protected readonly dialogRef = inject( protected readonly dialogRef =
MatDialogRef<GfHoldingDetailDialogComponent> inject<
); MatDialogRef<GfHoldingDetailDialogComponent, HoldingDetailDialogResult>
>(MatDialogRef);
private tags: Tag[]; private tags: Tag[];
@ -200,6 +204,17 @@ export class GfHoldingDetailDialogComponent implements OnInit {
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.router.events
.pipe(
filter((event) => {
return event instanceof NavigationStart;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.dialogRef.close({ isNavigating: true });
});
addIcons({ addIcons({
arrowDownCircleOutline, arrowDownCircleOutline,
createOutline, createOutline,
@ -566,9 +581,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag = hasPermission(
hasPermission(this.user.permissions, permissions.createOwnTag) && this.user?.permissions,
(this.user?.settings?.isExperimentalFeatures ?? false); permissions.createOwnTag
);
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags?.map((tag) => {
@ -589,17 +605,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.fetchActivities(); this.fetchActivities();
} }
protected onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, createDialog: true }
}
);
this.dialogRef.close();
}
protected onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -661,17 +666,6 @@ export class GfHoldingDetailDialogComponent implements OnInit {
} }
} }
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
{
queryParams: { activityId: aActivity.id, editDialog: true }
}
);
this.dialogRef.close();
}
private fetchActivities(filters: Filter[] = this.getActivityFilters()) { private fetchActivities(filters: Filter[] = this.getActivityFilters()) {
this.dataService this.dataService
.fetchActivities({ .fetchActivities({

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

@ -374,8 +374,6 @@
[sortDirection]="sortDirection" [sortDirection]="sortDirection"
[sortDisabled]="true" [sortDisabled]="true"
[totalItems]="activitiesCount" [totalItems]="activitiesCount"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()" (export)="onExport()"
(pageChanged)="onChangePage($event)" (pageChanged)="onChangePage($event)"
/> />

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

@ -15,3 +15,7 @@ export interface HoldingDetailDialogParams {
locale: string; locale: string;
symbol: string; symbol: string;
} }
export interface HoldingDetailDialogResult {
isNavigating?: boolean;
}

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

@ -1,16 +1,8 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component'; import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import { Benchmark, InfoItem, User } from '@ghostfolio/common/interfaces';
import { resetHours } from '@ghostfolio/common/helper';
import {
Benchmark,
HistoricalDataItem,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -29,11 +21,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [GfBenchmarkComponent, GfFearAndGreedIndexComponent],
GfBenchmarkComponent,
GfFearAndGreedIndexComponent,
GfLineChartComponent
],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-home-market', selector: 'gf-home-market',
styleUrls: ['./home-market.scss'], styleUrls: ['./home-market.scss'],
@ -41,15 +29,13 @@ import { DeviceDetectorService } from 'ngx-device-detector';
}) })
export class GfHomeMarketComponent implements OnInit { export class GfHomeMarketComponent implements OnInit {
protected readonly benchmarks = signal<Benchmark[]>([]); protected readonly benchmarks = signal<Benchmark[]>([]);
protected readonly deviceType = computed( protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
protected readonly fearAndGreedIndex = signal<number | undefined>(undefined);
protected readonly fearLabel = $localize`Fear`; protected fearAndGreedIndex: number | undefined;
protected readonly greedLabel = $localize`Greed`;
protected hasPermissionToAccessFearAndGreedIndex: boolean; protected hasPermissionToAccessFearAndGreedIndex: boolean;
protected readonly historicalDataItems = signal<HistoricalDataItem[]>([]);
protected readonly numberOfDays = 365;
protected user: User; protected user: User;
private readonly info: InfoItem; private readonly info: InfoItem;
@ -80,27 +66,8 @@ export class GfHomeMarketComponent implements OnInit {
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
if ( if (this.hasPermissionToAccessFearAndGreedIndex) {
this.hasPermissionToAccessFearAndGreedIndex && this.fearAndGreedIndex = this.info.fearAndGreedStocksMarketPrice;
this.info.fearAndGreedDataSource
) {
this.dataService
.fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex.set(marketPrice);
this.historicalDataItems.set([
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
]);
});
} }
this.dataService this.dataService

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

@ -1,28 +1,11 @@
<div class="container"> <div class="container">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
@if (hasPermissionToAccessFearAndGreedIndex) { @if (hasPermissionToAccessFearAndGreedIndex) {
<div class="mb-5 row"> <div class="mb-4 row">
<div class="col-xs-12 col-md-10 offset-md-1"> <div class="col-xs-12 col-md-10 offset-md-1">
<div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-3"
label="Fear & Greed Index"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems()"
[isAnimated]="true"
[locale]="user?.settings?.locale || undefined"
[showXAxis]="true"
[showYAxis]="true"
[yMax]="100"
[yMaxLabel]="greedLabel"
[yMin]="0"
[yMinLabel]="fearLabel"
/>
<gf-fear-and-greed-index <gf-fear-and-greed-index
class="d-flex justify-content-center" class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex()" [fearAndGreedIndex]="fearAndGreedIndex"
/> />
</div> </div>
</div> </div>

4
apps/client/src/app/components/home-market/home-market.scss

@ -1,7 +1,3 @@
:host { :host {
display: block; display: block;
gf-line-chart {
aspect-ratio: 16 / 9;
}
} }

4
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -41,7 +41,7 @@ import {
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import { type AnnotationOptions } from 'chartjs-plugin-annotation'; import { type AnnotationOptions } from 'chartjs-plugin-annotation';
import { isAfter } from 'date-fns'; import { isFuture } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
@ -311,6 +311,6 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
return undefined; return undefined;
} }
return isAfter(new Date(xValue), new Date()) ? aValue : undefined; return isFuture(new Date(xValue)) ? aValue : undefined;
} }
} }

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

@ -1,5 +1,8 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
@ -243,7 +246,8 @@ export class GfAccountsPageComponent implements OnInit {
private openAccountDetailDialog(aAccountId: string) { private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent, GfAccountDetailDialogComponent,
AccountDetailDialogParams AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, { >(GfAccountDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
@ -262,7 +266,11 @@ export class GfAccountsPageComponent implements OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe((result) => {
if (result?.isNavigating) {
return;
}
this.fetchAccounts(); this.fetchAccounts();
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });

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

@ -65,7 +65,7 @@ export class GfCreateOrUpdateAccountDialogComponent {
protected accountForm: FormGroup; protected accountForm: FormGroup;
protected currencies: string[] = []; protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined; protected filteredPlatforms: Observable<Platform[]> | undefined;
protected hasPermissionToCreateOwnTag: boolean | undefined; protected hasPermissionToCreateOwnTag: boolean;
protected platforms: Platform[] = []; protected platforms: Platform[] = [];
protected tagsAvailable: Tag[] = []; protected tagsAvailable: Tag[] = [];
@ -82,9 +82,10 @@ export class GfCreateOrUpdateAccountDialogComponent {
const { currencies } = this.dataService.fetchInfo(); const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.settings?.isExperimentalFeatures && this.data.user?.permissions,
hasPermission(this.data.user?.permissions, permissions.createOwnTag); permissions.createOwnTag
);
this.tagsAvailable = [ this.tagsAvailable = [
...(this.data.user?.tags ?? []), ...(this.data.user?.tags ?? []),

6
apps/client/src/app/pages/landing/landing-page.html

@ -247,11 +247,11 @@
@if (testimonial.url) { @if (testimonial.url) {
<a target="_blank" [href]="testimonial.url">{{ <a target="_blank" [href]="testimonial.url">{{
testimonial.author testimonial.author
}}</a> }}</a
>,
} @else { } @else {
<span>{{ testimonial.author }}</span> <span>{{ testimonial.author }},</span>
} }
,
{{ testimonial.country }} {{ testimonial.country }}
</div> </div>
</div> </div>

141
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -2,7 +2,6 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
@ -10,6 +9,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -29,17 +29,12 @@ import { PageEvent } from '@angular/material/paginator';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces';
import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component'; import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component';
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
import { ActivitiesPageParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -59,6 +54,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected hasImpersonationId: boolean; protected hasImpersonationId: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected hasPermissionToDeleteActivity: boolean; protected hasPermissionToDeleteActivity: boolean;
protected readonly internalRoutes = internalRoutes;
protected pageIndex = 0; protected pageIndex = 0;
protected readonly pageSize = DEFAULT_PAGE_SIZE; protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected sortColumn = 'date'; protected sortColumn = 'date';
@ -77,37 +73,9 @@ export class GfActivitiesPageComponent implements OnInit {
private readonly impersonationStorageService = inject( private readonly impersonationStorageService = inject(
ImpersonationStorageService ImpersonationStorageService
); );
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap((params: ActivitiesPageParams) => {
if (params.activityId && (params.createDialog || params.editDialog)) {
return this.dataService
.fetchActivity(params.activityId)
.pipe(map((activity) => ({ activity, params })));
}
return of({ params, activity: undefined });
})
)
.subscribe(({ activity, params }) => {
if (params.createDialog) {
this.openCreateActivityDialog(activity);
} else if (params.editDialog) {
if (activity) {
this.openUpdateActivityDialog(activity);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
@ -149,10 +117,6 @@ export class GfActivitiesPageComponent implements OnInit {
}); });
} }
protected onCloneActivity(aActivity: Activity) {
this.openCreateActivityDialog(aActivity);
}
protected onDeleteActivities() { protected onDeleteActivities() {
this.dataService this.dataService
.deleteActivities({ .deleteActivities({
@ -308,12 +272,6 @@ export class GfActivitiesPageComponent implements OnInit {
this.fetchActivities(); this.fetchActivities();
} }
protected onUpdateActivity(aActivity: Activity) {
this.router.navigate([], {
queryParams: { activityId: aActivity.id, editDialog: true }
});
}
private fetchActivities() { private fetchActivities() {
// Reset dataSource and totalItems to show loading state // Reset dataSource and totalItems to show loading state
this.dataSource = undefined; this.dataSource = undefined;
@ -343,45 +301,13 @@ export class GfActivitiesPageComponent implements OnInit {
this.hasPermissionToCreateActivity && this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0 this.user?.activitiesCount === 0
) { ) {
this.router.navigate([], { queryParams: { createDialog: true } }); void this.router.navigate(
} internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
this.changeDetectorRef.markForCheck(); );
});
} }
private openUpdateActivityDialog(aActivity: Activity) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity: aActivity,
accounts: this.user?.accounts,
user: this.user
},
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((activity: UpdateOrderDto) => {
if (activity) {
this.dataService
.putActivity(activity)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.fetchActivities();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
}); });
} }
@ -393,59 +319,6 @@ export class GfActivitiesPageComponent implements OnInit {
return /^\d{4}$/.test(dateRange); return /^\d{4}$/.test(dateRange);
} }
private openCreateActivityDialog(aActivity?: Activity) {
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.updateUser(user);
const dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
accounts: this.user?.accounts,
activity: {
...aActivity,
accountId: aActivity?.accountId,
assetProfile: aActivity?.assetProfile ?? null,
date: new Date(),
id: null,
fee: 0,
type: aActivity?.type ?? 'BUY',
unitPrice: null
},
user: this.user
} satisfies CreateOrUpdateActivityDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((transaction: CreateOrderDto | null) => {
if (transaction) {
this.dataService.postActivity(transaction).subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
this.fetchActivities();
this.changeDetectorRef.markForCheck();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private updateUser(aUser: User) { private updateUser(aUser: User) {
this.user = aUser; this.user = aUser;

11
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -25,8 +25,6 @@
(activitiesDeleted)="onDeleteActivities()" (activitiesDeleted)="onDeleteActivities()"
(activityClicked)="onClickActivity($event)" (activityClicked)="onClickActivity($event)"
(activityDeleted)="onDeleteActivity($event)" (activityDeleted)="onDeleteActivity($event)"
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()" (export)="onExport()"
(exportDrafts)="onExportDrafts($event)" (exportDrafts)="onExportDrafts($event)"
(import)="onImport()" (import)="onImport()"
@ -43,6 +41,13 @@
hasPermissionToCreateActivity && hasPermissionToCreateActivity &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
) { ) {
<gf-fab [queryParams]="{ createDialog: true }" /> <gf-fab
[routerLink]="
internalRoutes.portfolio.subRoutes.activities.subRoutes.create
.routerLink
"
/>
} }
</div> </div>
<router-outlet />

29
apps/client/src/app/pages/portfolio/activities/activities-page.routes.ts

@ -4,10 +4,39 @@ import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { GfActivitiesPageComponent } from './activities-page.component'; import { GfActivitiesPageComponent } from './activities-page.component';
import { GfActivityDialogHostComponent } from './activity-dialog-host/activity-dialog-host.component';
const { clone, create, update } =
internalRoutes.portfolio.subRoutes.activities.subRoutes;
export const routes: Routes = [ export const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'create' },
path: create.path,
title: create.title
},
{
children: [
{
component: GfActivityDialogHostComponent,
data: { mode: 'clone' },
path: clone.path,
title: clone.title
},
{
component: GfActivityDialogHostComponent,
data: { mode: 'update' },
path: update.path,
title: update.title
}
],
path: ':activityId'
}
],
component: GfActivitiesPageComponent, component: GfActivitiesPageComponent,
path: '', path: '',
title: internalRoutes.portfolio.subRoutes.activities.title title: internalRoutes.portfolio.subRoutes.activities.title

169
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts

@ -0,0 +1,169 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, User } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GfCreateOrUpdateActivityDialogComponent } from '../create-or-update-activity-dialog/create-or-update-activity-dialog.component';
import { CreateOrUpdateActivityDialogParams } from '../create-or-update-activity-dialog/interfaces/interfaces';
import { ActivityDialogMode } from './types/activity-dialog-mode.type';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activity-dialog-host',
template: ''
})
export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<GfCreateOrUpdateActivityDialogComponent>;
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public ngOnInit() {
const mode = this.route.snapshot.data.mode as ActivityDialogMode;
const activityId = this.route.snapshot.paramMap.get('activityId');
const activity$: Observable<Activity | undefined> = activityId
? this.dataService.fetchActivity(activityId)
: of(undefined);
this.userService
.get()
.pipe(
switchMap((user) => {
return activity$.pipe(
map((activity) => {
return { activity, user };
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => {
this.navigateBack();
},
next: ({ activity, user }) => {
if (mode === 'update') {
if (!activity) {
this.navigateBack();
return;
}
this.openDialog({ activity, user, isUpdate: true });
return;
}
if (mode === 'clone' && !activity) {
this.navigateBack();
return;
}
this.openDialog({
user,
activity: {
...activity,
accountId: activity?.accountId,
assetProfile: activity?.assetProfile ?? null,
date: new Date(),
fee: 0,
id: null,
type: activity?.type ?? 'BUY',
unitPrice: null
},
isUpdate: false
});
}
});
}
public ngOnDestroy() {
// The dialog lives in an overlay outside of this component, so it needs to
// be closed explicitly when leaving the route (for example via the browser
// navigation)
this.dialogRef?.close();
}
private navigateBack() {
void this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink
);
}
private openDialog({
activity,
isUpdate,
user
}: {
activity: CreateOrUpdateActivityDialogParams['activity'];
isUpdate: boolean;
user: User;
}) {
const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.dialogRef = this.dialog.open<
GfCreateOrUpdateActivityDialogComponent,
CreateOrUpdateActivityDialogParams
>(GfCreateOrUpdateActivityDialogComponent, {
data: {
activity,
user,
accounts: user?.accounts
},
height: deviceType === 'mobile' ? '98vh' : '80vh',
width: deviceType === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: CreateOrderDto | UpdateOrderDto | null) => {
if (!result) {
this.navigateBack();
return;
}
const request$: Observable<unknown> = isUpdate
? this.dataService.putActivity(result as UpdateOrderDto)
: this.dataService.postActivity(result as CreateOrderDto);
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
error: () => {
this.navigateBack();
},
next: () => {
// Deliberately not bound to the destroy reference: navigating back
// destroys this component and the refreshed user is what makes the
// activities page reload its data
this.userService.get(true).subscribe();
this.navigateBack();
}
});
});
}
}

1
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/types/activity-dialog-mode.type.ts

@ -0,0 +1 @@
export type ActivityDialogMode = 'clone' | 'create' | 'update';

63
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -92,7 +92,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
protected currentMarketPrice: number | null = null; protected currentMarketPrice: number | null = null;
protected defaultDateFormat: string; protected defaultDateFormat: string;
protected defaultLookupItems: LookupItem[] = []; protected defaultLookupItems: LookupItem[] = [];
protected hasPermissionToCreateOwnTag: boolean | undefined; protected hasPermissionToCreateOwnTag: boolean;
protected isLoading = false; protected isLoading = false;
protected readonly isToday = isToday; protected readonly isToday = isToday;
protected mode: 'create' | 'update'; protected mode: 'create' | 'update';
@ -120,9 +120,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
public ngOnInit() { public ngOnInit() {
this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency; this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency;
this.hasPermissionToCreateOwnTag = this.hasPermissionToCreateOwnTag = hasPermission(
this.data.user?.settings?.isExperimentalFeatures && this.data.user?.permissions,
hasPermission(this.data.user?.permissions, permissions.createOwnTag); permissions.createOwnTag
);
this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE; this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE;
this.mode = this.data.activity?.id ? 'update' : 'create'; this.mode = this.data.activity?.id ? 'update' : 'create';
@ -265,16 +266,9 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.activityForm.get('currency')?.setValue(currency); this.activityForm.get('currency')?.setValue(currency);
this.activityForm.get('currencyOfUnitPrice')?.setValue(currency); this.activityForm.get('currencyOfUnitPrice')?.setValue(currency);
if (['FEE', 'INTEREST'].includes(type)) {
if (this.activityForm.get('accountId')?.value) {
this.activityForm.get('updateAccountBalance')?.enable();
} else {
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
}
}
} }
this.syncUpdateAccountBalanceControl();
}); });
this.activityForm this.activityForm
@ -298,12 +292,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
}); });
this.activityForm.get('date')?.valueChanges.subscribe(() => { this.activityForm.get('date')?.valueChanges.subscribe(() => {
if (isToday(this.activityForm.get('date')?.value)) { this.syncUpdateAccountBalanceControl();
this.activityForm.get('updateAccountBalance')?.enable();
} else {
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -383,8 +372,6 @@ export class GfCreateOrUpdateActivityDialogComponent {
.get('searchSymbol') .get('searchSymbol')
?.removeValidators(Validators.required); ?.removeValidators(Validators.required);
this.activityForm.get('searchSymbol')?.updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
} else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { } else if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
const currency = const currency =
this.data.accounts.find(({ id }) => { this.data.accounts.find(({ id }) => {
@ -420,16 +407,6 @@ export class GfCreateOrUpdateActivityDialogComponent {
if (type === 'FEE') { if (type === 'FEE') {
this.activityForm.get('unitPrice')?.setValue(0); this.activityForm.get('unitPrice')?.setValue(0);
} }
if (
['FEE', 'INTEREST'].includes(type) &&
this.activityForm.get('accountId')?.value
) {
this.activityForm.get('updateAccountBalance')?.enable();
} else {
this.activityForm.get('updateAccountBalance')?.disable();
this.activityForm.get('updateAccountBalance')?.setValue(false);
}
} else { } else {
this.activityForm this.activityForm
.get('dataSource') .get('dataSource')
@ -441,9 +418,10 @@ export class GfCreateOrUpdateActivityDialogComponent {
.get('searchSymbol') .get('searchSymbol')
?.setValidators(Validators.required); ?.setValidators(Validators.required);
this.activityForm.get('searchSymbol')?.updateValueAndValidity(); this.activityForm.get('searchSymbol')?.updateValueAndValidity();
this.activityForm.get('updateAccountBalance')?.enable();
} }
this.syncUpdateAccountBalanceControl();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -558,6 +536,27 @@ export class GfCreateOrUpdateActivityDialogComponent {
} }
} }
private syncUpdateAccountBalanceControl() {
const accountBalanceControl = this.activityForm.get('updateAccountBalance');
const accountId = this.activityForm.get('accountId')?.value;
const dataSource = this.activityForm.get('dataSource')?.value;
const date = this.activityForm.get('date')?.value;
const type = this.activityForm.get('type')?.value;
const isEligible =
!!accountId &&
isToday(date) &&
!['LIABILITY', 'VALUABLE'].includes(type) &&
!(dataSource === 'MANUAL' && type === 'BUY');
if (isEligible) {
accountBalanceControl?.enable();
} else {
accountBalanceControl?.disable();
accountBalanceControl?.setValue(false);
}
}
private updateAssetProfile() { private updateAssetProfile() {
this.isLoading = true; this.isLoading = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -7,7 +7,6 @@ import {
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Activity, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
@ -66,7 +65,6 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
GfDialogFooterComponent, GfDialogFooterComponent,
GfDialogHeaderComponent, GfDialogHeaderComponent,
GfFileDropDirective, GfFileDropDirective,
GfSymbolPipe,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -51,7 +51,7 @@
> >
<br /> <br />
<small class="text-muted" <small class="text-muted"
>{{ holding.assetProfile.symbol | gfSymbol }} · >{{ holding.assetProfile.symbol }} ·
{{ holding.assetProfile.currency }}</small {{ holding.assetProfile.currency }}</small
> >
</mat-option> </mat-option>

7
apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts

@ -1,7 +0,0 @@
import { Params } from '@angular/router';
export interface ActivitiesPageParams extends Params {
activityId?: string;
createDialog?: string;
editDialog?: string;
}

27
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -1,9 +1,15 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCountryName, prettifySymbol } from '@ghostfolio/common/helper'; import {
canOpenHoldingDetail,
getCountryName
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
HoldingWithParents, HoldingWithParents,
@ -113,6 +119,7 @@ export class GfAllocationsPageComponent implements OnInit {
protected symbols: { protected symbols: {
[name: string]: { [name: string]: {
dataSource?: DataSource; dataSource?: DataSource;
isClickable?: boolean;
name: string; name: string;
symbol: string; symbol: string;
value: number; value: number;
@ -492,10 +499,11 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf += this.holdings[symbol].value; this.totalValueInEtf += this.holdings[symbol].value;
} }
this.symbols[prettifySymbol(symbol)] = { this.symbols[symbol] = {
symbol,
dataSource: position.assetProfile.dataSource, dataSource: position.assetProfile.dataSource,
isClickable: canOpenHoldingDetail(position),
name: position.assetProfile.name ?? '', name: position.assetProfile.name ?? '',
symbol: prettifySymbol(symbol),
value: value:
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? position.valueInBaseCurrency
@ -562,11 +570,11 @@ export class GfAllocationsPageComponent implements OnInit {
return currentParentHolding && return currentParentHolding &&
isNumber(currentParentHolding.valueInBaseCurrency) isNumber(currentParentHolding.valueInBaseCurrency)
? { ? {
symbol,
allocationInPercentage: allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value, currentParentHolding.valueInBaseCurrency / value,
name: holding.assetProfile.name ?? '', name: holding.assetProfile.name ?? '',
position: holding, position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency: valueInBaseCurrency:
currentParentHolding.valueInBaseCurrency currentParentHolding.valueInBaseCurrency
} }
@ -604,7 +612,8 @@ export class GfAllocationsPageComponent implements OnInit {
private openAccountDetailDialog(aAccountId: string) { private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent, GfAccountDetailDialogComponent,
AccountDetailDialogParams AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, { >(GfAccountDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
@ -623,7 +632,11 @@ export class GfAllocationsPageComponent implements OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe((result) => {
if (result?.isNavigating) {
return;
}
void this.router.navigate(['.'], { relativeTo: this.route }); void this.router.navigate(['.'], { relativeTo: this.route });
}); });
} }

8
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -6,6 +6,7 @@ import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { canOpenHoldingDetail } from '@ghostfolio/common/helper';
import { import {
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
@ -365,8 +366,11 @@ export class GfAnalysisPageComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
const holdingsSorted = sortBy( const holdingsSorted = sortBy(
holdings.filter(({ netPerformancePercentWithCurrencyEffect }) => { holdings.filter((holding) => {
return isNumber(netPerformancePercentWithCurrencyEffect); return (
canOpenHoldingDetail(holding) &&
isNumber(holding.netPerformancePercentWithCurrencyEffect)
);
}), }),
'netPerformancePercentWithCurrencyEffect' 'netPerformancePercentWithCurrencyEffect'
).reverse(); ).reverse();

15
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -1,6 +1,7 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { formatMonthAndYear } from '@ghostfolio/common/helper';
import { import {
FireCalculationCompleteEvent, FireCalculationCompleteEvent,
FireWealth, FireWealth,
@ -77,6 +78,20 @@ export class GfFirePageComponent implements OnInit {
); );
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
protected get retirementDateLabel(): string {
const retirementDate =
this.user?.settings?.retirementDate ?? this.retirementDate;
if (!retirementDate) {
return '';
}
return formatMonthAndYear({
date: new Date(retirementDate),
locale: this.user?.settings?.locale
});
}
public ngOnInit() { public ngOnInit() {
this.isLoading = true; this.isLoading = true;

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

Loading…
Cancel
Save