Browse Source

Merge remote-tracking branch 'origin/main' into mr/Upstream-changes-2025-03-12

pull/5027/head
Dan 4 months ago
parent
commit
bd105a51d3
  1. 4
      .github/ISSUE_TEMPLATE/bug_report.md
  2. 110
      CHANGELOG.md
  3. 9
      DEVELOPMENT.md
  4. 13
      SECURITY.md
  5. 18
      apps/api/src/app/account/account.service.ts
  6. 2
      apps/api/src/app/admin/admin.module.ts
  7. 57
      apps/api/src/app/admin/admin.service.ts
  8. 8
      apps/api/src/app/app.module.ts
  9. 33
      apps/api/src/app/auth/web-auth.service.ts
  10. 36
      apps/api/src/app/benchmark/benchmark.module.ts
  11. 11
      apps/api/src/app/endpoints/ai/ai.controller.ts
  12. 7
      apps/api/src/app/endpoints/ai/ai.service.ts
  13. 40
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  14. 63
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  15. 163
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  16. 36
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  17. 43
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  18. 5
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  19. 3
      apps/api/src/app/endpoints/public/public.controller.ts
  20. 10
      apps/api/src/app/endpoints/tags/create-tag.dto.ts
  21. 87
      apps/api/src/app/endpoints/tags/tags.controller.ts
  22. 12
      apps/api/src/app/endpoints/tags/tags.module.ts
  23. 13
      apps/api/src/app/endpoints/tags/update-tag.dto.ts
  24. 3
      apps/api/src/app/export/export.module.ts
  25. 55
      apps/api/src/app/export/export.service.ts
  26. 2
      apps/api/src/app/import/import.service.ts
  27. 2
      apps/api/src/app/info/info.module.ts
  28. 2
      apps/api/src/app/info/info.service.ts
  29. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  30. 4
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  31. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  32. 28
      apps/api/src/app/portfolio/portfolio.service.ts
  33. 6
      apps/api/src/app/tag/create-tag.dto.ts
  34. 14
      apps/api/src/app/tag/tag.module.ts
  35. 9
      apps/api/src/app/tag/update-tag.dto.ts
  36. 28
      apps/api/src/app/user/user.service.ts
  37. 3
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  38. 1
      apps/api/src/middlewares/html-template.middleware.ts
  39. 77
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  40. 77
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  41. 24
      apps/api/src/services/benchmark/benchmark.module.ts
  42. 12
      apps/api/src/services/benchmark/benchmark.service.spec.ts
  43. 162
      apps/api/src/services/benchmark/benchmark.service.ts
  44. 0
      apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts
  45. 5
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  46. 5
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  47. 90
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  48. 5
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  49. 42
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  50. 68
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  51. 5
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  52. 10
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  53. 5
      apps/api/src/services/data-provider/manual/manual.service.ts
  54. 5
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  55. 5
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  56. 3
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  57. 11
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  58. 73
      apps/api/src/services/tag/tag.service.ts
  59. 2
      apps/api/src/services/twitter-bot/twitter-bot.module.ts
  60. 2
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  61. 34
      apps/client/localhost.cert
  62. 52
      apps/client/localhost.pem
  63. 9
      apps/client/src/app/app.component.html
  64. 4
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  65. 29
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  66. 19
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  67. 10
      apps/client/src/app/components/admin-overview/admin-overview.html
  68. 2
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts
  69. 14
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  70. 4
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  71. 2
      apps/client/src/app/components/asset-profile-icon/asset-profile-icon.component.ts
  72. 86
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  73. 24
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  74. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  75. 2
      apps/client/src/app/components/home-overview/home-overview.html
  76. 9
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  77. 2
      apps/client/src/app/components/rule/rule.component.scss
  78. 4
      apps/client/src/app/core/http-response.interceptor.ts
  79. 3
      apps/client/src/app/core/notification/alert-dialog/alert-dialog.component.ts
  80. 3
      apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.component.ts
  81. 2
      apps/client/src/app/core/notification/prompt-dialog/prompt-dialog.component.ts
  82. 20
      apps/client/src/app/pages/about/overview/about-overview-page.html
  83. 4
      apps/client/src/app/pages/api/api-page.component.ts
  84. 5
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  85. 30
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  86. 1
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  87. 1
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  88. 11
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  89. 22
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  90. 45
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  91. 14
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss
  92. 25
      apps/client/src/app/pages/register/register-page.component.ts
  93. 2
      apps/client/src/app/pages/register/register-page.html
  94. 57
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.component.ts
  95. 89
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.html
  96. 4
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.module.ts
  97. 4
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.scss
  98. 3
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  99. 20
      apps/client/src/app/services/admin.service.ts
  100. 49
      apps/client/src/app/services/data.service.ts

4
.github/ISSUE_TEMPLATE/bug_report.md

@ -26,7 +26,7 @@ Thank you for your understanding and cooperation!
2.
3.
**Expected behavior**
**Expected Behavior**
<!-- A clear and concise description of what you expected to happen. -->
@ -48,6 +48,6 @@ Thank you for your understanding and cooperation!
- Browser
- OS
**Additional context**
**Additional Context**
<!-- Add any other context about the problem here. -->

110
CHANGELOG.md

@ -96,13 +96,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Improved the usability of the user account registration
## 2.145.1 - 2025-03-10
### Added
- Extended the export functionality by the account balances
- Added a _Copy portfolio data to clipboard for AI prompt_ action to the analysis page (experimental)
### Changed
- Improved the style of the summary on the _X-ray_ page
- Improved the language localization for German (`de`)
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `9.0` to `13.1`
### Fixed
- Fixed an issue to get dividends in the _Financial Modeling Prep_ service
- Fixed an issue to get historical market data in the _Financial Modeling Prep_ service
- Fixed an issue with serving _Storybook_
## 2.144.0 - 2025-03-06
### Fixed
- Fixed the missing import functionality on the non-empty activities page
- Fixed the functionality to delete an asset profile of a custom currency in the admin control panel
## 2.143.0 - 2025-03-02
### Added
- Added the Ghostfolio _LinkedIn_ page to the about page
- Added the Ghostfolio _LinkedIn_ page to the footer
### Changed
- Optimized the asynchronous operations using `Promise.all()` in the portfolio service (`getPerformance`)
- Improved the symbol lookup in the _Trackinsight_ data enhancer for asset profile data
- Removed the no transactions info component from the holdings table on the home page
- Refactored the show condition of the step by step introduction for new users using the activities count
- Upgraded `color` from version `4.2.3` to `5.0.0`
- Upgraded `prisma` from version `6.3.0` to `6.4.1`
### Fixed
- Handled an exception in the export functionality related to platforms
- Handled an exception in the benchmark service related to unnamed asset profiles
## 2.142.0 - 2025-02-28
### Added
- Extended the export functionality by the platforms
- Extended the portfolio snapshot in the portfolio calculator by the `createdAt` timestamp
- Extended the _Trackinsight_ data enhancer for asset profile data by `cusip`
- Added _Storybook_ to the build process
### Changed
- Upgraded `eslint` dependencies
## 2.141.0 - 2025-02-25
### Added
- Extended the export functionality by the tags
- Extended the portfolio snapshot in the portfolio calculator by the activities count
- Extended the user endpoint `GET api/v1/user` by the activities count
- Added `cusip` to the asset profile model
### Changed
- Upgraded `prettier` from version `3.4.2` to `3.5.1`
### Fixed
- Improved the numeric comparison of strings in the value component
## 2.140.0 - 2025-02-20
### Changed
- Reloaded the available tags after creating a custom tag in the holding detail dialog (experimental)
- Improved the validation of the currency management in the admin control panel
- Migrated the `@ghostfolio/client` components to control flow
- Migrated the `@ghostfolio/ui` components to control flow
- Improved the language localization for German (`de`)
### Fixed
- Improved the error handling in the `HttpResponseInterceptor`
- Fixed an issue while using symbol profile overrides in the historical market data table of the admin control panel
- Added missing assets in _Storybook_ setup
## 2.139.1 - 2025-02-15
### Added
- Extended the tooltip in the chart of the holdings tab on the home page by the allocation, change and performance
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Asia-Pacific Markets)
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Japan)
- Added support to create custom tags in the holding detail dialog (experimental)
- Extended the tags selector component by a `readonly` attribute
- Extended the tags selector component to support creating custom tags
- Extended the holding detail dialog by the historical market data editor (experimental)
- Added global styles to the _Storybook_ setup
### Changed
- Improved the symbol lookup in the _Trackinsight_ data enhancer for asset profile data
- Improved the language localization for German (`de`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `5.2.1` to `5.2.2`
### Fixed
- Fixed the gaps in the chart of the benchmark comparator
## 2.138.0 - 2025-02-08

9
DEVELOPMENT.md

@ -101,3 +101,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
## SSL
Generate `localhost.cert` and `localhost.pem` files.
```
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
```

13
SECURITY.md

@ -0,0 +1,13 @@
# Security Policy
## Reporting Security Issues
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps.
To help us resolve the issue, please include the following details:
- A description of the vulnerability
- Steps to reproduce the vulnerability
- Affected versions of the software
We appreciate your responsible disclosure and will work to address the issue promptly.

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

@ -8,7 +8,13 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import {
Account,
AccountBalance,
Order,
Platform,
Prisma
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash';
@ -57,13 +63,19 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
balances?: AccountBalance[];
Order?: Order[];
Platform?: Platform;
})[]
> {
const { include = {}, skip, take, cursor, where, orderBy } = params;
include.balances = { orderBy: { date: 'desc' }, take: 1 };
const isBalancesIncluded = !!include.balances;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
const accounts = await this.prismaService.account.findMany({
cursor,
@ -77,7 +89,9 @@ export class AccountService {
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
if (!isBalancesIncluded) {
delete account.balances;
}
return account;
});

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

@ -1,8 +1,8 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';

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

@ -1,7 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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';
@ -30,6 +30,7 @@ import {
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
@ -108,8 +109,27 @@ export class AdminService {
symbol
}: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol);
const customCurrencies = (await this.propertyService.getByKey(
PROPERTY_CURRENCIES
)) as string[];
if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter(
(customCurrency) => {
return customCurrency !== currency;
}
);
await this.putSetting(
PROPERTY_CURRENCIES,
JSON.stringify(updatedCustomCurrencies)
);
} else {
await this.symbolProfileService.delete({ dataSource, symbol });
}
}
public async get(): Promise<AdminData> {
const exchangeRates = this.exchangeRateDataService
@ -260,6 +280,7 @@ export class AdminService {
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true,
tags: true
}
}),
@ -315,11 +336,10 @@ export class AdminService {
Order,
sectors,
symbol,
SymbolProfileOverrides,
tags
}) => {
const countriesCount = countries
? Object.keys(countries).length
: 0;
let countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
@ -333,7 +353,34 @@ export class AdminService {
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
)?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])
?.length > 0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return {
assetClass,

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

@ -29,13 +29,14 @@ import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
@ -49,7 +50,6 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module';
@Module({
@ -63,7 +63,7 @@ import { UserModule } from './user/user.module';
AssetModule,
AuthDeviceModule,
AuthModule,
BenchmarkModule,
BenchmarksModule,
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -124,7 +124,7 @@ import { UserModule } from './user/user.module';
SitemapModule,
SubscriptionModule,
SymbolModule,
TagModule,
TagsModule,
TwitterBotModule,
UserModule
],

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

@ -24,6 +24,7 @@ import {
verifyRegistrationResponse,
VerifyRegistrationResponseOpts
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import {
AssertionCredentialJSON,
@ -54,10 +55,9 @@ export class WebAuthService {
const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userID: isoUint8Array.fromUTF8String(user.id),
userName: '',
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: false,
@ -111,11 +111,17 @@ export class WebAuthService {
where: { userId: user.id }
});
if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo;
const {
credential: {
counter,
id: credentialId,
publicKey: credentialPublicKey
}
} = registrationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
let existingDevice = devices.find((device) => {
return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
});
if (!existingDevice) {
/**
@ -123,7 +129,7 @@ export class WebAuthService {
*/
existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialId: Buffer.from(credentialID),
credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } }
});
@ -148,9 +154,8 @@ export class WebAuthService {
const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [
{
id: device.credentialId,
transports: ['internal'],
type: 'public-key'
id: isoBase64URL.fromBuffer(device.credentialId),
transports: ['internal']
}
],
rpID: this.rpID,
@ -187,10 +192,10 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAuthenticationResponseOpts = {
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
credential: {
counter: device.counter,
id: isoBase64URL.fromBuffer(device.credentialId),
publicKey: device.credentialPublicKey
},
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,

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

@ -1,36 +0,0 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
@Module({
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

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

@ -6,9 +6,9 @@ import {
} from '@ghostfolio/common/config';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, Param, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -21,11 +21,14 @@ export class AiController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('prompt')
@Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(): Promise<AiPromptResponse> {
public async getPrompt(
@Param('mode') mode: AiPromptMode
): Promise<AiPromptResponse> {
const prompt = await this.aiService.getPrompt({
mode,
impersonationId: undefined,
languageCode:
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,

7
apps/api/src/app/endpoints/ai/ai.service.ts

@ -1,4 +1,5 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -9,11 +10,13 @@ export class AiService {
public async getPrompt({
impersonationId,
languageCode,
mode,
userCurrency,
userId
}: {
impersonationId: string;
languageCode: string;
mode: AiPromptMode;
userCurrency: string;
userId: string;
}) {
@ -43,6 +46,10 @@ export class AiService {
)
];
if (mode === 'portfolio') {
return holdingsTable.join('\n');
}
return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable,

40
apps/api/src/app/benchmark/benchmark.controller.ts → apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -2,7 +2,10 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
@ -16,6 +19,7 @@ import {
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
@ -29,12 +33,14 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service';
import { BenchmarksService } from './benchmarks.service';
@Controller('benchmark')
export class BenchmarkController {
@Controller('benchmarks')
export class BenchmarksController {
public constructor(
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -108,23 +114,43 @@ export class BenchmarkController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max'
@Query('range') dateRange: DateRange = 'max',
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
new Date(startDateString)
);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataForUser({
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({
dataSource,
dateRange,
endDate,
filters,
impersonationId,
startDate,
symbol,
userCurrency
withExcludedAccounts,
user: this.request.user
});
}
}

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

@ -0,0 +1,63 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarksController } from './benchmarks.controller';
import { BenchmarksService } from './benchmarks.service';
@Module({
controllers: [BenchmarksController],
imports: [
ApiModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
BenchmarkService,
BenchmarksService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class BenchmarksModule {}

163
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -0,0 +1,163 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns';
import { isNumber } from 'lodash';
@Injectable()
export class BenchmarksService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly portfolioService: PortfolioService,
private readonly symbolService: SymbolService
) {}
public async getMarketDataForUser({
dataSource,
dateRange,
endDate = new Date(),
filters,
impersonationId,
startDate,
symbol,
user,
withExcludedAccounts
}: {
dateRange: DateRange;
endDate?: Date;
filters?: Filter[];
impersonationId: string;
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.Settings.settings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
userId,
withExcludedAccounts
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: chart.map(({ date }) => {
return resetHours(parseDate(date));
})
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return {
marketData
};
}
}

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

@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
@ -37,6 +38,41 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('asset-profile/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getAssetProfile(
@Param('symbol') symbol: string
): Promise<DataProviderGhostfolioAssetProfileResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const assetProfile = await this.ghostfolioService.getAssetProfile({
symbol
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return assetProfile;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
/**
* @deprecated
*/

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -15,6 +16,7 @@ import {
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
@ -25,7 +27,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
@Injectable()
@ -37,6 +39,44 @@ export class GhostfolioService {
private readonly propertyService: PropertyService
) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
const promises: Promise<Partial<SymbolProfile>>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then((assetProfile) => {
result = {
...result,
...assetProfile,
dataSource: DataSource.GHOSTFOLIO
};
return assetProfile;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getDividends({
from,
granularity,
@ -277,6 +317,7 @@ export class GhostfolioService {
});
results.items = filteredItems;
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');

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

@ -1,6 +1,7 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -42,7 +43,7 @@ export class MarketDataController {
{ dataSource, symbol }
]);
if (!assetProfile) {
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
@ -55,7 +56,7 @@ export class MarketDataController {
);
const canReadOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile

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

@ -57,7 +57,7 @@ export class PublicController {
}
const [
{ holdings, markets },
{ createdAt, holdings, markets },
{ performance: performance1d },
{ performance: performanceMax },
{ performance: performanceYtd }
@ -81,6 +81,7 @@ export class PublicController {
});
const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails,
markets,
alias: access.alias,

10
apps/api/src/app/endpoints/tags/create-tag.dto.ts

@ -0,0 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

87
apps/api/src/app/tag/tag.controller.ts → apps/api/src/app/endpoints/tags/tags.controller.ts

@ -1,6 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -8,41 +10,63 @@ import {
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(private readonly tagService: TagService) {}
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Controller('tags')
export class TagsController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Post()
@HasPermission(permissions.createTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
const canCreateOwnTag = hasPermission(
this.request.user.permissions,
permissions.createOwnTag
);
const canCreateTag = hasPermission(
this.request.user.permissions,
permissions.createTag
);
if (!canCreateOwnTag && !canCreateTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (canCreateOwnTag && !canCreateTag) {
if (data.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
return this.tagService.createTag(data);
}
@HasPermission(permissions.updateTag)
@Put(':id')
@Delete(':id')
@HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({
id
});
@ -54,20 +78,20 @@ export class TagController {
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
return this.tagService.deleteTag({ id });
}
});
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Delete(':id')
@HasPermission(permissions.deleteTag)
@HasPermission(permissions.updateTag)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) {
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
const originalTag = await this.tagService.getTag({
id
});
@ -79,6 +103,13 @@ export class TagController {
);
}
return this.tagService.deleteTag({ id });
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
}

12
apps/api/src/app/endpoints/tags/tags.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
@Module({
controllers: [TagsController],
imports: [PrismaModule, TagModule]
})
export class TagsModule {}

13
apps/api/src/app/endpoints/tags/update-tag.dto.ts

@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

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

@ -1,6 +1,7 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -8,7 +9,7 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [AccountModule, ApiModule, OrderModule],
imports: [AccountModule, ApiModule, OrderModule, TagModule],
controllers: [ExportController],
providers: [ExportService]
})

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

@ -1,15 +1,18 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { Filter, Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Platform } from '@prisma/client';
@Injectable()
export class ExportService {
public constructor(
private readonly accountService: AccountService,
private readonly orderService: OrderService
private readonly orderService: OrderService,
private readonly tagService: TagService
) {}
public async export({
@ -23,17 +26,40 @@ export class ExportService {
userCurrency: string;
userId: string;
}): Promise<Export> {
const platformsMap: { [platformId: string]: Platform } = {};
const accounts = (
await this.accountService.accounts({
include: {
balances: true,
Platform: true
},
orderBy: {
name: 'asc'
},
where: { userId }
})
).map(
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
Platform: platform,
platformId
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment,
currency,
id,
@ -60,9 +86,22 @@ export class ExportService {
});
}
const tags = (await this.tagService.getTagsForUser(userId))
.filter(({ isUsed }) => {
return isUsed;
})
.map(({ id, name }) => {
return {
id,
name
};
});
return {
meta: { date: new Date().toISOString(), version: environment.version },
accounts,
platforms: Object.values(platformsMap),
tags,
activities: activities.map(
({
accountId,
@ -72,6 +111,7 @@ export class ExportService {
id,
quantity,
SymbolProfile,
tags: currentTags,
type,
unitPrice
}) => {
@ -86,13 +126,12 @@ export class ExportService {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol:
type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
? SymbolProfile.name
: SymbolProfile.symbol
: SymbolProfile.symbol,
tags: currentTags.map(({ id: tagId }) => {
return tagId;
})
};
}
),

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

@ -293,6 +293,7 @@ export class ImportService {
assetSubClass,
countries,
createdAt,
cusip,
dataSource,
figi,
figiComposite,
@ -367,6 +368,7 @@ export class ImportService {
assetSubClass,
countries,
createdAt,
cusip,
dataSource,
figi,
figiComposite,

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

@ -1,8 +1,8 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';

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

@ -1,7 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';

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

@ -172,6 +172,8 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) {
return {
activitiesCount: 0,
createdAt: new Date(),
currentValueInBaseCurrency: new Big(0),
errors: [],
hasErrors: false,

4
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -101,6 +101,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),

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

@ -108,6 +108,7 @@ export class PortfolioController {
const {
accounts,
createdAt,
hasErrors,
holdings,
markets,
@ -254,6 +255,7 @@ export class PortfolioController {
return {
accounts,
createdAt,
hasError,
holdings,
platforms,

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

@ -16,8 +16,10 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -381,7 +383,7 @@ export class PortfolioService {
currency: userCurrency
});
const { currentValueInBaseCurrency, hasErrors, positions } =
const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
await portfolioCalculator.getSnapshot();
const cashDetails = await this.accountService.getCashDetails({
@ -626,6 +628,7 @@ export class PortfolioService {
return {
accounts,
createdAt,
hasErrors,
holdings,
markets,
@ -1108,19 +1111,18 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const accountBalanceItems =
await this.accountBalanceService.getAccountBalanceItems({
const [accountBalanceItems, { activities }] = await Promise.all([
this.accountBalanceService.getAccountBalanceItems({
filters,
userId,
userCurrency
});
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
}),
this.orderService.getOrdersForPortfolioCalculator({
filters,
userCurrency,
userId
});
})
]);
if (accountBalanceItems.length === 0 && activities.length === 0) {
return {
@ -1309,6 +1311,11 @@ export class PortfolioService {
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new RegionalMarketClusterRiskAsiaPacific(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.asiaPacific.valueInBaseCurrency
),
new RegionalMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
@ -1319,6 +1326,11 @@ export class PortfolioService {
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.europe.valueInBaseCurrency
),
new RegionalMarketClusterRiskJapan(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.japan.valueInBaseCurrency
),
new RegionalMarketClusterRiskNorthAmerica(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,

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

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

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

@ -1,14 +0,0 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

9
apps/api/src/app/tag/update-tag.dto.ts

@ -1,9 +0,0 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

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

@ -13,8 +13,10 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
@ -84,6 +86,9 @@ export class UserService {
orderBy: { alias: 'asc' },
where: { GranteeUser: { id } }
}),
this.prismaService.order.count({
where: { userId: id }
}),
this.prismaService.order.findFirst({
orderBy: {
date: 'asc'
@ -94,8 +99,9 @@ export class UserService {
]);
const access = userData[0];
const firstActivity = userData[1];
let tags = userData[2];
const activitiesCount = userData[1];
const firstActivity = userData[2];
let tags = userData[3];
let systemMessage: SystemMessage;
@ -115,6 +121,7 @@ export class UserService {
}
return {
activitiesCount,
id,
permissions,
subscription,
@ -272,6 +279,12 @@ export class UserService {
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskAsiaPacific:
new RegionalMarketClusterRiskAsiaPacific(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskEmergingMarkets:
new RegionalMarketClusterRiskEmergingMarkets(
undefined,
@ -283,6 +296,11 @@ export class UserService {
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskJapan: new RegionalMarketClusterRiskJapan(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskNorthAmerica:
new RegionalMarketClusterRiskNorthAmerica(
undefined,
@ -333,7 +351,11 @@ export class UserService {
currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess,
permissions.readAiPrompt
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile
);
// Reset benchmark

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

@ -33,9 +33,12 @@ export class TransformDataSourceInResponseInterceptor<T>
attribute: 'dataSource',
valueMap: Object.keys(DataSource).reduce(
(valueMap, dataSource) => {
if (!['MANUAL'].includes(dataSource)) {
valueMap[dataSource] = encodeDataSource(
DataSource[dataSource]
);
}
return valueMap;
},
{}

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

@ -129,6 +129,7 @@ export const HtmlTemplateMiddleware = async (
if (
path.startsWith('/api/') ||
path.startsWith('/development/storybook') ||
isFileRequest(path) ||
!environment.production
) {

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

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
private asiaPacificValueInBaseCurrency: number;
private currentValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
asiaPacificValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskAsiaPacific.name,
name: 'Asia-Pacific'
});
this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency;
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const asiaPacificMarketValueRatio = this.currentValueInBaseCurrency
? this.asiaPacificValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (asiaPacificMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (asiaPacificMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.03,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.02
};
}
}

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

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private japanValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
japanValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskJapan.name,
name: 'Japan'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.japanValueInBaseCurrency = japanValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const japanMarketValueRatio = this.currentValueInBaseCurrency
? this.japanValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (japanMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (japanMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.06,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.04
};
}
}

24
apps/api/src/services/benchmark/benchmark.module.ts

@ -0,0 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkService } from './benchmark.service';
@Module({
exports: [BenchmarkService],
imports: [
DataProviderModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

12
apps/api/src/app/benchmark/benchmark.service.spec.ts → apps/api/src/services/benchmark/benchmark.service.spec.ts

@ -4,17 +4,7 @@ describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService;
beforeAll(async () => {
benchmarkService = new BenchmarkService(
null,
null,
null,
null,
null,
null,
null,
null,
null
);
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
});
it('calculateChangeInPercentage', async () => {

162
apps/api/src/app/benchmark/benchmark.service.ts → apps/api/src/services/benchmark/benchmark.service.ts

@ -1,8 +1,5 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -11,16 +8,10 @@ import {
CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
calculateBenchmarkTrend,
parseDate,
resetHours
} from '@ghostfolio/common/helper';
import { calculateBenchmarkTrend } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,
BenchmarkProperty,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
@ -29,16 +20,8 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import {
addHours,
differenceInDays,
eachDayOfInterval,
format,
isAfter,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, uniqBy } from 'lodash';
import { addHours, isAfter, subDays } from 'date-fns';
import { uniqBy } from 'lodash';
import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@ -48,15 +31,12 @@ export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
private readonly symbolProfileService: SymbolProfileService
) {}
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
@ -153,139 +133,9 @@ export class BenchmarkService {
symbol
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
public async getMarketDataForUser({
dataSource,
endDate = new Date(),
startDate,
symbol,
userCurrency
}: {
endDate?: Date;
startDate: Date;
userCurrency: string;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
const dates = eachDayOfInterval(
{
start: startDate,
end: endDate
},
{
step: Math.round(
days /
Math.min(days, this.configurationService.get('MAX_CHART_ITEMS'))
)
}
).map((date) => {
return resetHours(date);
.sort((a, b) => {
return a.name?.localeCompare(b?.name) ?? 0;
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: dates
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return {
marketData
};
}
public async addBenchmark({

0
apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts → apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -41,9 +42,7 @@ export class AlphaVantageService implements DataProviderInterface {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
return {
symbol,
dataSource: this.getName()

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -56,9 +57,7 @@ export class CoinGeckoService implements DataProviderInterface {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {
symbol,
assetClass: AssetClass.LIQUIDITY,

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

@ -43,61 +43,55 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
let trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol
});
if (!trackinsightSymbol) {
trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol: symbol.split('.')?.[0]
});
}
if (!trackinsightSymbol) {
return response;
}
const profile = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.catch(() => {
return fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${
symbol.split('.')?.[0]
}.json`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
)
.then((res) => res.json())
.catch(() => {
return {};
});
});
const isin = profile?.isin?.split(';')?.[0];
const cusip = profile?.cusip;
if (cusip) {
response.cusip = cusip;
}
const isin = profile?.isins?.[0];
if (isin) {
response.isin = isin;
}
const holdings = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
)
.then((res) => res.json())
.catch(() => {
return fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.catch(() => {
return {};
});
});
if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) {
// Skip if data is inaccurate, dependent on holdings count there might be rounding issues
@ -177,4 +171,36 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getTestSymbol() {
return 'QQQ';
}
private async searchTrackinsightSymbol({
requestTimeout,
symbol
}: {
requestTimeout: number;
symbol: string;
}) {
return fetch(
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.then((jsonRes) => {
if (
jsonRes['results']?.['count'] === 1 ||
// Allow exact match
jsonRes['results']?.['docs']?.[0]?.['ticker'] === symbol ||
// Allow EXCHANGE:SYMBOL
jsonRes['results']?.['docs']?.[0]?.['ticker']?.endsWith(`:${symbol}`)
) {
return jsonRes['results']['docs'][0]['ticker'];
}
return undefined;
})
.catch(() => {
return undefined;
});
}
}

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -51,9 +52,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(symbol);
return {

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

@ -2,6 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -56,10 +57,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {
symbol,
dataSource: this.getName()
@ -70,9 +70,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [quote] = await fetch(
`${this.URL}/quote/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
@ -84,9 +82,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [assetProfile] = await fetch(
`${this.URL}/profile/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
@ -100,9 +96,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const etfCountryWeightings = await fetch(
`${this.URL}/etf-country-weightings/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
@ -127,9 +121,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [etfInformation] = await fetch(
`${this.getUrl({ version: 4 })}/etf-info?symbol=${symbol}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
@ -140,9 +132,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [portfolioDate] = await fetch(
`${this.getUrl({ version: 4 })}/etf-holdings/portfolio-date?symbol=${symbol}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
@ -150,9 +140,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const etfHoldings = await fetch(
`${this.getUrl({ version: 4 })}/etf-holdings?date=${portfolioDate.date}&symbol=${symbol}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
@ -170,9 +158,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const etfSectorWeightings = await fetch(
`${this.URL}/etf-sector-weightings/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
@ -211,7 +197,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
if (error?.name === 'AbortError') {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
requestTimeout / 1000
).toFixed(3)} seconds`;
}
@ -244,7 +230,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[date: string]: IDataProviderHistoricalResponse;
} = {};
const { historical } = await fetch(
const { historical = [] } = await fetch(
`${this.URL}/historical-price-full/stock_dividend/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
@ -305,7 +291,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
? addYears(currentFrom, MAX_YEARS_PER_REQUEST)
: to;
const { historical } = await fetch(
const { historical = [] } = await fetch(
`${this.URL}/historical-price-full/${symbol}?apikey=${this.apiKey}&from=${format(currentFrom, DATE_FORMAT)}&to=${format(currentTo, DATE_FORMAT)}`,
{
signal: AbortSignal.timeout(requestTimeout)
@ -376,7 +362,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
if (error?.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
requestTimeout / 1000
).toFixed(3)} seconds`;
}

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

@ -2,6 +2,7 @@ import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -18,6 +19,7 @@ import {
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
@ -46,21 +48,46 @@ export class GhostfolioService implements DataProviderInterface {
}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const { items } = await this.search({ query: symbol });
const searchResult = items?.[0];
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
let response: DataProviderGhostfolioAssetProfileResponse = {};
return {
symbol,
assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass,
currency: searchResult?.currency,
dataSource: this.getName(),
name: searchResult?.name
};
try {
const assetProfile = (await fetch(
`${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{
headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) =>
res.json()
)) as DataProviderGhostfolioAssetProfileResponse;
response = assetProfile;
} catch (error) {
let message = error;
if (error.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
requestTimeout / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
if (!error.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
}
Logger.error(message, 'GhostfolioService');
}
return response;
}
public getDataProviderInfo(): DataProviderInfo {
@ -203,7 +230,7 @@ export class GhostfolioService implements DataProviderInterface {
if (error.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
requestTimeout / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
@ -224,10 +251,13 @@ export class GhostfolioService implements DataProviderInterface {
}
public getTestSymbol() {
return 'AAPL.US';
return 'AAPL';
}
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
public async search({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
query
}: GetSearchParams): Promise<LookupResponse> {
let searchResult: LookupResponse = { items: [] };
try {
@ -235,9 +265,7 @@ export class GhostfolioService implements DataProviderInterface {
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
{
headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json())) as LookupResponse;
} catch (error) {
@ -245,7 +273,7 @@ export class GhostfolioService implements DataProviderInterface {
if (error.name === 'AbortError') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
requestTimeout / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -37,9 +38,7 @@ export class GoogleSheetsService implements DataProviderInterface {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
return {
symbol,
dataSource: this.getName()

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

@ -15,9 +15,7 @@ export interface DataProviderInterface {
getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>>;
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>>;
getDataProviderInfo(): DataProviderInfo;
@ -55,6 +53,11 @@ export interface DataProviderInterface {
search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>;
}
export interface GetAssetProfileParams {
requestTimeout?: number;
symbol: string;
}
export interface GetDividendsParams {
from: Date;
granularity?: Granularity;
@ -79,5 +82,6 @@ export interface GetQuotesParams {
export interface GetSearchParams {
includeIndices?: boolean;
query: string;
requestTimeout?: number;
userId?: string;
}

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -44,9 +45,7 @@ export class ManualService implements DataProviderInterface {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const assetProfile: Partial<SymbolProfile> = {
symbol,
dataSource: this.getName()

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -33,9 +34,7 @@ export class RapidApiService implements DataProviderInterface {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
return {
symbol,
dataSource: this.getName()

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

@ -2,6 +2,7 @@ import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/c
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
@ -43,9 +44,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
}

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

@ -220,6 +220,7 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
cusip,
dataSource,
figi,
figiComposite,
@ -238,6 +239,7 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
cusip,
dataSource,
figi,
figiComposite,
@ -254,6 +256,7 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
cusip,
figi,
figiComposite,
figiShareClass,

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

@ -217,8 +217,7 @@ export class SymbolProfileService {
?.length > 0
) {
item.countries = this.getCountries(
item.SymbolProfileOverrides
?.countries as unknown as Prisma.JsonArray
item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
);
}
@ -227,22 +226,22 @@ export class SymbolProfileService {
?.length > 0
) {
item.holdings = this.getHoldings(
item.SymbolProfileOverrides?.holdings as unknown as Prisma.JsonArray
item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray
);
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.name = item.SymbolProfileOverrides.name ?? item.name;
if (
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
item.sectors = this.getSectors(
item.SymbolProfileOverrides?.sectors as unknown as Prisma.JsonArray
item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
);
}
item.url = item.SymbolProfileOverrides?.url ?? item.url;
item.url = item.SymbolProfileOverrides.url ?? item.url;
delete item.SymbolProfileOverrides;
}

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

@ -1,11 +1,52 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsForUser(userId: string) {
const tags = await this.prismaService.tag.findMany({
include: {
@ -42,4 +83,36 @@ export class TagService {
isUsed: true
}));
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

2
apps/api/src/services/twitter-bot/twitter-bot.module.ts

@ -1,5 +1,5 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';

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

@ -1,5 +1,5 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
ghostfolioFearAndGreedIndexDataSource,

34
apps/client/localhost.cert

@ -1,18 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIC5TCCAc2gAwIBAgIJAJAMHOFnJ6oyMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMMCWxvY2FsaG9zdDAeFw0yNDAyMjcxNTQ2MzFaFw0yNDAzMjgxNTQ2MzFaMBQx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAJ0hRjViikEKVIyukXR4CfuYVvFEFzB6AwAQ9Jrz2MseqpLacLTXFFAS54mp
iDuqPBzs9ta40mlSrqSBuAWKikpW5kTNnmqUnDZ6iSJezbYWx9YyULGqqb1q3C4/
5pH9m6NHJ+2uaUNKlDxYNKbntqs3drQEdxH9yv672Z53nvogTcf9jz6zjutEQGSV
HgVkCTTQmzf3+3st+VJ5D8JeYFR+tpZ6yleqgXFaTMtPZRfKLvTkQ+KeyCJLnsUJ
BQvdCKI0PGsG6d6ygXFmSePolD9KW3VTKKDPCsndID89vAnRWDj9UhzvycxmKpcF
GrUPH5+Pis1PM1R7OnAvnFygnyECAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo
b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B
AQsFAAOCAQEAOdzrY3RTAPnBVAd3/GKIEkielYyOgKPnvd+RcOB+je8cXZY+vaxX
uEFP0526G00+kRZ2Tx9t0SmjoSpwg3lHUPMko0dIxgRlqISDAohdrEptHpcVujsD
ak88LLnAurr60JNjWX2wbEoJ18KLtqGSnATdmCgKwDPIN5a7+ssp44BGyzH6VYCg
wV3VjJl0pp4C5M0Jfu0p1FrQjzIVhgqR7JFYmvqIogWrGwYAQK/3XRXq8t48J5X3
IsfWiLAA2ZdCoWAnZ6PAGBOoGivtkJm967pHjd/28qYY6mQo4sN2ksEOjx6/YslF
2mOJdLV/DzqoggUsTpPEG0dRhzQLTGHKDQ==
MIIDSDCCAjACCQCQ2ForVhz+uDANBgkqhkiG9w0BAQsFADBmMQswCQYDVQQGEwJD
SDEOMAwGA1UECAwFU3RhdGUxDTALBgNVBAcMBENpdHkxFTATBgNVBAoMDE9yZ2Fu
aXphdGlvbjENMAsGA1UECwwEVW5pdDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1
MDMwOTE2MzQxM1oXDTI2MDMwOTE2MzQxM1owZjELMAkGA1UEBhMCQ0gxDjAMBgNV
BAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5MRUwEwYDVQQKDAxPcmdhbml6YXRpb24x
DTALBgNVBAsMBFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAMkJRKPgV8NDcoIakPc7sZVXQ9VK2PGb8+lF/1Lv
NcIZpD40+p4DzuEw0bjRn17IDClyLMaLbZNtIyTPSkFaffL+rJ0JvnKdG50s+HId
YNuCwKkgHg4hTXFzOPpT3HMG3UxyEwFOm25GMFiikfT96ukMAAkanMqYKZQOClRU
Cw4LP3g0Oks58obbRy4Wltp88K8LJrR+j81+AjElTIGXHhChXzV/NjJ14TMNy5hZ
lwV4xUSwvNqOvWGMIR7J77fINF130ghTSnvzCS52dCeom2I4Lvncz3m37lDttCOs
Wm/i651ro7pwFEs/lJmrnFHPtph2ayPcHBmrQCgLc5xMUMcCAwEAATANBgkqhkiG
9w0BAQsFAAOCAQEAhRA1/+Gl2VH34yN/FvrE5cY0W4ghSCuTdK9pGeo8AcN+TScU
7O+hVsEwZDrYKuDvG8Ab//A+uv5gbfGbYPJVIdJ3Q8HKijNZmbwAgANJU/c0WwOx
XBQ9mCzWRcJxQeUUgh4DT4lZCOfR5pIvAJpKScTcF/yp5gOgrgJH1GHFEYYPoXWO
ezPPMwCNbfamUPlZZnHu74fUrFrDPI9c/YSu8Ex/LegZXJAEzA+8I0g64rjGtzJp
fkRDyQcBuT5SVa+USBlALQmdIuT/fN6R729DcGzvV8JqdoG9sLra4hrRCn3+A3c9
izZguW1BQNQ2N7II6QCDnWkdUFSQCiQunX/xsg==
-----END CERTIFICATE-----

52
apps/client/localhost.pem

@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCdIUY1YopBClSM
rpF0eAn7mFbxRBcwegMAEPSa89jLHqqS2nC01xRQEueJqYg7qjwc7PbWuNJpUq6k
gbgFiopKVuZEzZ5qlJw2eokiXs22FsfWMlCxqqm9atwuP+aR/ZujRyftrmlDSpQ8
WDSm57arN3a0BHcR/cr+u9med576IE3H/Y8+s47rREBklR4FZAk00Js39/t7LflS
eQ/CXmBUfraWespXqoFxWkzLT2UXyi705EPinsgiS57FCQUL3QiiNDxrBunesoFx
Zknj6JQ/Slt1UyigzwrJ3SA/PbwJ0Vg4/VIc78nMZiqXBRq1Dx+fj4rNTzNUezpw
L5xcoJ8hAgMBAAECggEAPU5YOEgELTA8oM8TjV+wdWuQsH2ilpVkSkhTR4nQkh+a
6cU0qDoqgLt/fySYNL9MyPRjso9V+SX7YdAC3paZMjwJh9q57lehQ1g33SMkG+Fz
gs0K0ucFZxQkaB8idN+ANAp1N7UO+ORGRe0cTeqmSNNRCxea5XgiFZVxaPS/IFOR
vVdXS1DulgwHh4H0ljKmkj7jc9zPBSc9ccW5Ml2q4a26Atu4IC/Mlp/DF4GNELbD
ebi9ZOZG33ip2bdhj3WX7NW9GJaaViKtVUpcpR6u+6BfvTXQ6xrqdoxXk9fnPzzf
sSoLPTt8yO4RavP1zQU22PhgIcWudhCiy/2Nv+uLqQKBgQDMPh1/xwdFl8yES8dy
f0xJyCDWPiB+xzGwcOAC90FrNZaqG0WWs3VHE4cilaWjCflvdR6aAEDEY68sZJhl
h+ChT/g61QLMOI+VIDQ1kJXKYgfS/B+BE1PZ0EkuymKFOvbNO8agHodB8CqnZaQh
bLuZaDnZ0JLK4im3KPt70+aKYwKBgQDE8s6xl0SLu+yJLo3VklCRD67Z8/jlvx2t
h3DF6NG8pB3QmvKdJWFKuLAWLGflJwbUW9Pt3hXkc0649KQrtiIYC0ZMh9KMaVCk
WmjS/n2eIUQZ7ZUlwFesi4p4iGynVBavIY8TJ6Y+K3TvsJgXP3IZ96r689PQJo8E
KbSeyYzFqwKBgGQTS4EAlJ+U8bEhMGj51veQCAbyChoUoFRD+n95h6RwbZKMKlzd
MenRt7VKfg6VJJNoX8Y1uYaBEaQ+5i1Zlsdz1718AhLu4+u+C9bzMXIo9ox63TTx
s3RWioVSxVNiwOtvDrQGQWAdvcioFPQLwyA34aDIgiTHDImimxbhjWThAoGAWOqW
Tq9QjxWk0Lpn5ohMP3GpK1VuhasnJvUDARb/uf8ORuPtrOz3Y9jGBvy9W0OnXbCn
mbiugZldbTtl8yYjdl+AuYSIlkPl2I3IzZl/9Shnqp0MvSJ9crT9KzXMeC8Knr6z
7Z30/BR6ksxTngtS5E5grzPt6Qe/gc2ich3kpEkCgYBfBHUhVIKVrDW/8a7U2679
Wj4i9us/M3iPMxcTv7/ZAg08TEvNBQYtvVQLu0NfDKXx8iGKJJ6evIYgNXVm2sPq
VzSgwGCALi1X0443amZU+mnX+/9RnBqyM+PJU8570mM83jqKlY/BEsi0aqxTioFG
an3xbjjN+Rq9iKLzmPxIMg==
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJCUSj4FfDQ3KC
GpD3O7GVV0PVStjxm/PpRf9S7zXCGaQ+NPqeA87hMNG40Z9eyAwpcizGi22TbSMk
z0pBWn3y/qydCb5ynRudLPhyHWDbgsCpIB4OIU1xczj6U9xzBt1MchMBTptuRjBY
opH0/erpDAAJGpzKmCmUDgpUVAsOCz94NDpLOfKG20cuFpbafPCvCya0fo/NfgIx
JUyBlx4QoV81fzYydeEzDcuYWZcFeMVEsLzajr1hjCEeye+3yDRdd9IIU0p78wku
dnQnqJtiOC753M95t+5Q7bQjrFpv4uuda6O6cBRLP5SZq5xRz7aYdmsj3BwZq0Ao
C3OcTFDHAgMBAAECggEBALJQqDN7QB0QbDb+fWrt5bwDJUXBF+BmZdiZn7jeOJ6r
w8TxlQIneo6/kKYQOP4HDtKMVS7eaRkFCtERlFmXfHPWdSDtjaF3vRCS3OPLLyhF
N8JLnJ0H6PsiKn3PeJAGnK+71yOnp7IOS7+yoyfdOUnwvO9WTZBdmzOZqIvX595R
g7R5yjSYjzFMmaCpyab6kiD7b4bHzDIrB0XuouT2W/fS/i1srwc3eDk78ZyioyiB
g6GDuOwqDfPmkUqKo2oXSoSR8yCwSSdlClc7aOowoNxbsksTDjXf1k02n0lG5MHU
ldCX02WdA0JFW8Os0Ig+YBq7wSkB5oNVt7gEek/MB0ECgYEA+ATkyfX9/5X2kUMY
MatUqKOvLUtyIfHeYUy/Dm+9JlZlrxD0dRAKWhnhRR16v5cwN2RVtEvMDsV1AGHN
e/fh315aAq+I6/eY6syXfkeHHs5UIRPrOlIcp1Ogfg99xpOT0/TZy9bB7lKvtYes
GmKO8n7md1TptdxilSNORI60KvECgYEAz4FX6vH76HgV/seY+vePrj5nFCZnDru7
16w5LYoHaX0hABJ0qZCqZozdPf9mqM5Ldc8PUbvVsFqXyaHwBAKUsH44a3aLxcXU
JMsQanU5I87SWP/S8Xu2Yxc10L66Oc5VdAeraZvb+wJqTkYKhDYOJVMjyuI/vkAw
fqMPI6wShzcCgYEAndYnb6uf4Eakap9jR0C8mLHKaq3nzVhqaEt6DwrnOf2jqnzE
xbbWj66GoQB4vHLP2YB91kaibwgURJD5PxpqYUdfSvRA08J3S322L0P/5ofyHDbb
7PqSh539thvPtE74tdvNux5Jvoxai9Dyorv0Mri1nF2qefTtu/GC/rg+SlECgYB2
FaYhhomTVls1/QIat6zlPI/OULhPExineFOljaoAJvwTnW0UXcYKy9jPgjs6jwM0
TJvsKFdHn5ZHYUdEEO/qrDmRNgn+h0Ddm02BN6pHrVfY2+SAFaXKKBgw7YjugnPw
rrimRdLeuhYi6wrrCBPuu6xftXcO3lp6hnKEG1UD6wKBgEh/C7HQ6cjb7Rr15eRq
2VOgeuz7o2v/OC+jO6yFGRrs2VKoBuJpw/6jx806Cbi2jLEwim21iNYW/2McOWP3
YUvni7qHXfll8d4sSAuCTA4K/N0MJ/3XbGBPDm/83J2o7uz2GFkQRruruaERvDMF
x26H2i3DOUFzdgbkoNB0ifHd
-----END PRIVATE KEY-----

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

@ -129,6 +129,15 @@
>GitHub<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://linkedin.com/company/ghostfolio"
target="_blank"
title="Follow Ghostfolio on LinkedIn"
>LinkedIn<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li>
<a
class="align-items-baseline d-flex"

4
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -4,7 +4,8 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import {
getCurrencyFromSymbol,
isDerivedCurrency
isDerivedCurrency,
isRootCurrency
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -77,6 +78,7 @@ export class AdminMarketDataService {
activitiesCount === 0 &&
!isBenchmark &&
!isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!isRootCurrency(getCurrencyFromSymbol(symbol)) &&
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
);
}

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

@ -15,9 +15,11 @@ import {
FormControl,
FormGroup,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { isISO4217CurrencyCode } from 'class-validator';
import { uniq } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
@ -52,9 +54,7 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.createAssetProfileForm = this.formBuilder.group(
{
addCurrency: new FormControl(null, [
Validators.maxLength(3),
Validators.minLength(3),
Validators.required
this.iso4217CurrencyCodeValidator()
]),
addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required])
@ -83,11 +83,11 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol
});
} else if (this.mode === 'currency') {
const currency = this.createAssetProfileForm
.get('addCurrency')
.value.toUpperCase();
const currency = (
this.createAssetProfileForm.get('addCurrency').value as string
).toUpperCase();
const currencies = uniq([...this.customCurrencies, currency]);
const currencies = uniq([...this.customCurrencies, currency]).sort();
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {
@ -109,10 +109,7 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
const addCurrencyFormControl =
this.createAssetProfileForm.get('addCurrency');
if (
addCurrencyFormControl.hasError('maxlength') ||
addCurrencyFormControl.hasError('minlength')
) {
if (addCurrencyFormControl.hasError('invalidCurrency')) {
return true;
}
@ -161,4 +158,14 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.changeDetectorRef.markForCheck();
});
}
private iso4217CurrencyCodeValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!isISO4217CurrencyCode(control.value?.toUpperCase())) {
return { invalidCurrency: true };
}
return null;
};
}
}

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

@ -28,7 +28,6 @@ import {
formatDistanceToNowStrict,
parseISO
} from 'date-fns';
import { uniq } from 'lodash';
import { StringValue } from 'ms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -122,24 +121,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
}
public onAddCurrency() {
const currency = prompt($localize`Please add a currency:`);
if (currency) {
if (currency.length === 3) {
const currencies = uniq([
...this.customCurrencies,
currency.toUpperCase()
]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
} else {
this.notificationService.alert({
title: $localize`${currency} is an invalid currency!`
});
}
}
}
public onChangeCouponDuration(aCouponDuration: StringValue) {
this.couponDuration = aCouponDuration;
}

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

@ -95,16 +95,6 @@
</tr>
}
</table>
<div class="mt-2">
<button
color="primary"
mat-flat-button
(click)="onAddCurrency()"
>
<ion-icon class="mr-1" name="add-outline" />
<span i18n>Add Currency</span>
</button>
</div>
</div>
</div>
<div class="d-flex my-3">

2
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts

@ -3,7 +3,6 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
@ -18,7 +17,6 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
@Component({
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfPremiumIndicatorComponent,

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

@ -1,8 +1,7 @@
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -49,7 +48,6 @@ export class AdminTagComponent implements OnInit, OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
@ -106,7 +104,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
}
private deleteTag(aId: string) {
this.adminService
this.dataService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
@ -122,7 +120,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
}
private fetchTags() {
this.adminService
this.dataService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
@ -154,7 +152,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tag: CreateTagDto | null) => {
if (tag) {
this.adminService
this.dataService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
@ -190,7 +188,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tag: UpdateTagDto | null) => {
if (tag) {
this.adminService
this.dataService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({

4
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts

@ -1,5 +1,5 @@
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {

2
apps/client/src/app/components/asset-profile-icon/asset-profile-icon.component.ts

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
@ -10,7 +9,6 @@ import { DataSource } from '@prisma/client';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-asset-profile-icon',
styleUrls: ['./asset-profile-icon.component.scss'],

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

@ -13,8 +13,10 @@ import {
LineChartItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -44,11 +46,11 @@ import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { Account, Tag } from '@prisma/client';
import { Account, MarketData, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { switchMap, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -62,6 +64,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfDataProviderCreditsComponent,
GfDialogFooterModule,
GfDialogHeaderModule,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfPortfolioProportionChartComponent,
GfTagsSelectorComponent,
@ -96,9 +99,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
public hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[];
public investment: number;
public investmentPrecision = 2;
public marketDataItems: MarketData[] = [];
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
@ -150,7 +156,34 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.activityForm
.get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
.subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => {
return id === undefined;
});
if (newTag && this.hasPermissionToCreateOwnTag) {
this.dataService
.postTag({ ...newTag, userId: this.user.id })
.pipe(
switchMap((createdTag) => {
return this.dataService.putHoldingTags({
dataSource: this.data.dataSource,
symbol: this.data.symbol,
tags: [
...tags.filter(({ id }) => {
return id !== undefined;
}),
createdTag
]
});
}),
switchMap(() => {
return this.userService.get(true);
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe();
} else {
this.dataService
.putHoldingTags({
tags,
@ -159,6 +192,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
}
});
this.dataService
@ -235,6 +269,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.hasPermissionToReadMarketDataOfOwnAssetProfile =
hasPermission(
this.user?.permissions,
permissions.readMarketDataOfOwnAssetProfile
) &&
SymbolProfile?.dataSource === 'MANUAL' &&
SymbolProfile?.userId === this.user?.id;
this.historicalDataItems = historicalData.map(
({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({
@ -398,6 +440,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}
);
if (this.hasPermissionToReadMarketDataOfOwnAssetProfile) {
this.fetchMarketData();
}
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
@ -421,6 +467,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOwnTag = hasPermission(
this.user.permissions,
permissions.createOwnTag
);
this.tagsAvailable =
this.user?.tags?.map((tag) => {
return {
@ -466,6 +517,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
});
}
public onMarketDataChanged(withRefresh = false) {
if (withRefresh) {
this.fetchMarketData();
}
}
public onTagsChanged(tags: Tag[]) {
this.activityForm.get('tags').setValue(tags);
}
@ -482,4 +539,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchMarketData() {
this.dataService
.fetchMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataItems = marketData;
this.historicalDataItems = this.marketDataItems.map(
({ date, marketPrice }) => {
return {
date: format(date, DATE_FORMAT),
value: marketPrice
};
}
);
this.changeDetectorRef.markForCheck();
});
}
}

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

@ -368,9 +368,33 @@
[showValueInBaseCurrency]="false"
/>
</mat-tab>
@if (
hasPermissionToReadMarketDataOfOwnAssetProfile &&
user?.settings?.isExperimentalFeatures
) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="server-outline" />
<div class="d-none d-sm-block ml-2" i18n>Market Data</div>
</ng-template>
<gf-historical-market-data-editor
[currency]="SymbolProfile?.currency"
[dataSource]="SymbolProfile?.dataSource"
[dateOfFirstActivity]="firstBuyDate"
[locale]="data.locale"
[marketData]="marketDataItems"
[symbol]="SymbolProfile?.symbol"
[user]="user"
(marketDataChanged)="onMarketDataChanged($event)"
/>
</mat-tab>
}
</mat-tab-group>
<gf-tags-selector
[hasPermissionToCreateTag]="
hasPermissionToCreateOwnTag && user?.settings?.isExperimentalFeatures
"
[readonly]="!data.hasPermissionToUpdateOrder"
[tags]="activityForm.get('tags')?.value"
[tagsAvailable]="tagsAvailable"

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

@ -48,7 +48,6 @@
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
(holdingClicked)="onHoldingClicked($event)"

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

@ -1,7 +1,7 @@
<div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
@if (hasPermissionToCreateOrder && historicalDataItems?.length === 0) {
@if (hasPermissionToCreateOrder && user?.activitiesCount === 0) {
<div class="justify-content-center row w-100">
<div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4>

9
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -1,6 +1,5 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@ -14,13 +13,7 @@ import { MatSliderModule } from '@angular/material/slider';
import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@Component({
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatSliderModule
],
imports: [FormsModule, MatButtonModule, MatDialogModule, MatSliderModule],
selector: 'gf-rule-settings-dialog',
styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html'

2
apps/client/src/app/components/rule/rule.component.scss

@ -2,7 +2,7 @@
display: block;
.icon-container {
background-color: rgba(var(--dark-primary-text), 0.05);
background-color: rgba(var(--palette-foreground-base), 0.02);
border-radius: 0.25rem;
height: 2rem;

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

@ -108,12 +108,14 @@ export class HttpResponseInterceptor implements HttpInterceptor {
});
}
} else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']);
} else if (!error.url.includes('/data-providers/ghostfolio/status')) {
} else {
this.tokenStorageService.signOut();
}
}
}
return throwError(error);
})

3
apps/client/src/app/core/notification/alert-dialog/alert-dialog.component.ts

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@ -6,7 +5,7 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { IAlertDialogParams } from './interfaces/interfaces';
@Component({
imports: [CommonModule, MatButtonModule, MatDialogModule],
imports: [MatButtonModule, MatDialogModule],
selector: 'gf-alert-dialog',
styleUrls: ['./alert-dialog.scss'],
templateUrl: './alert-dialog.html'

3
apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.component.ts

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@ -7,7 +6,7 @@ import { ConfirmationDialogType } from './confirmation-dialog.type';
import { IConfirmDialogParams } from './interfaces/interfaces';
@Component({
imports: [CommonModule, MatButtonModule, MatDialogModule],
imports: [MatButtonModule, MatDialogModule],
selector: 'gf-confirmation-dialog',
styleUrls: ['./confirmation-dialog.scss'],
templateUrl: './confirmation-dialog.html'

2
apps/client/src/app/core/notification/prompt-dialog/prompt-dialog.component.ts

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@ -8,7 +7,6 @@ import { MatInputModule } from '@angular/material/input';
@Component({
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,

20
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -73,6 +73,14 @@
>.
</p>
<p class="align-items-center d-flex justify-content-center">
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack community"
>
<ion-icon name="logo-slack" />
</a>
<a
class="mx-2"
href="https://x.com/ghostfolio_"
@ -93,19 +101,19 @@
}
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Join the Ghostfolio Slack community"
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-slack" />
<ion-icon name="logo-github" />
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
href="https://linkedin.com/company/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
title="Follow Ghostfolio on LinkedIn"
>
<ion-icon name="logo-github" />
<ion-icon name="logo-linkedin" />
</a>
</p>
@if (hasPermissionForSubscription) {

4
apps/client/src/app/pages/api/api-page.component.ts

@ -40,8 +40,8 @@ export class GfApiPageComponent implements OnInit {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO.US'] });
this.status$ = this.fetchStatus();
this.symbols$ = this.fetchSymbols({ query: 'apple' });
}

5
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -56,6 +56,11 @@
<li>Click on the <i>+</i> button</li>
<li>Switch to <i>Add Currency</i></li>
<li>Insert e.g. <code>EUR</code> for Euro</li>
<li>Select <i>Filter by Currencies</i></li>
<li>Find the entry <i>USDEUR</i></li>
<li>
Click the menu item <i>Gather Historical Data</i> in the dialog
</li>
</ol>
</mat-card-content>
</mat-card>

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

@ -125,7 +125,10 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
if (this.hasPermissionToCreateActivity && this.totalItems <= 0) {
if (
this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0
) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
@ -160,6 +163,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities();
});
}
@ -169,6 +177,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.deleteActivity(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities();
});
}
@ -230,6 +243,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities();
});
}
@ -248,6 +266,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities();
});
}
@ -333,6 +356,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
if (transaction) {
this.dataService.postOrder(transaction).subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities();
}
});

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

@ -6,6 +6,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="deviceType"
[hasActivities]="user?.activitiesCount > 0"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId"

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

@ -268,6 +268,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.platforms = {};
this.portfolioDetails = {
accounts: {},
createdAt: undefined,
holdings: {},
platforms: {},
summary: undefined

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

@ -12,7 +12,11 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange, GroupBy } from '@ghostfolio/common/types';
import type {
AiPromptMode,
DateRange,
GroupBy
} from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Clipboard } from '@angular/cdk/clipboard';
@ -169,9 +173,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.fetchDividendsAndInvestments();
}
public onCopyPromptToClipboard() {
public onCopyPromptToClipboard(mode: AiPromptMode) {
this.dataService
.fetchPrompt()
.fetchPrompt(mode)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ prompt }) => {
this.clipboard.copy(prompt);
@ -349,6 +353,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.fetchBenchmarkForUser({
dataSource,
symbol,
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange,
startDate: this.firstOrderDate
})

22
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -16,7 +16,7 @@
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard()"
(click)="onCopyPromptToClipboard('portfolio')"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
@ -24,7 +24,25 @@
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
<ng-container i18n>Copy AI prompt to clipboard</ng-container>
<ng-container i18n
>Copy portfolio data to clipboard for AI prompt</ng-container
>
</span>
</button>
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard('analysis')"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="mr-2" />
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
<ng-container i18n
>Copy AI prompt to clipboard for analysis</ng-container
>
</span>
</button>
</mat-menu>

45
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -7,23 +7,58 @@
risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy.
</p>
<p class="mb-4">
<div class="d-flex pb-3">
@if (isLoading) {
<div class="d-flex">
<ngx-skeleton-loader
animation="pulse"
class="w-100"
class="mr-2"
[theme]="{
height: '2rem',
width: '2rem'
}"
/>
<ngx-skeleton-loader
animation="pulse"
class="mt-2 mb-1"
[theme]="{
height: '1rem',
width: '100%'
width: '15rem'
}"
/>
</div>
} @else {
<div
class="align-items-center d-flex icon-container mr-2 px-2"
[ngClass]="{
okay:
statistics?.rulesFulfilledCount ===
statistics?.rulesActiveCount,
warn:
statistics?.rulesFulfilledCount !== statistics?.rulesActiveCount
}"
>
@if (
statistics?.rulesActiveCount === 0 ||
statistics?.rulesFulfilledCount === 0
) {
<ion-icon class="text-muted" name="remove-circle-outline" />
} @else if (
statistics?.rulesFulfilledCount === statistics?.rulesActiveCount
) {
<ion-icon name="checkmark-circle-outline" />
} @else {
{{ statistics?.rulesFulfilledCount }}
<ion-icon name="warning-outline" />
}
</div>
<div class="d-flex align-items-center">
<ng-container>{{ statistics?.rulesFulfilledCount }} </ng-container>
<ng-container i18n>out of</ng-container>
{{ statistics?.rulesActiveCount }}
<ng-container i18n>rules align with your portfolio.</ng-container>
</div>
}
</p>
</div>
<div
class="mb-4"
[ngClass]="{

14
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss

@ -1,3 +1,17 @@
:host {
display: block;
.icon-container {
background-color: rgba(var(--palette-foreground-base), 0.02);
border-radius: 0.25rem;
height: 2rem;
&.okay {
color: var(--success);
}
&.warn {
color: var(--danger);
}
}
}

25
apps/client/src/app/pages/register/register-page.component.ts

@ -7,7 +7,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { Role } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -59,15 +58,6 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
);
}
public async createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken, role }) => {
this.openShowAccessTokenDialog(accessToken, authToken, role);
});
}
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();
@ -76,17 +66,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
} catch {}
}
public openShowAccessTokenDialog(
accessToken: string,
authToken: string,
role: Role
) {
public openShowAccessTokenDialog() {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,
authToken,
role
},
disableClose: true,
width: '30rem'
});
@ -94,8 +75,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
if (data?.authToken) {
.subscribe((authToken) => {
if (authToken) {
this.tokenStorageService.saveToken(authToken, true);
this.router.navigate(['/']);

2
apps/client/src/app/pages/register/register-page.html

@ -22,7 +22,7 @@
class="d-inline-block"
color="primary"
mat-flat-button
(click)="createAccount()"
(click)="openShowAccessTokenDialog()"
>
<ng-container i18n>Create Account</ng-container>
</button>

57
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.component.ts

@ -1,19 +1,58 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ViewChild
} from '@angular/core';
import { MatStepper } from '@angular/material/stepper';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-show-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-show-access-token-dialog',
standalone: false,
styleUrls: ['./show-access-token-dialog.scss'],
templateUrl: 'show-access-token-dialog.html',
standalone: false
templateUrl: 'show-access-token-dialog.html'
})
export class ShowAccessTokenDialog {
public isAgreeButtonDisabled = true;
@ViewChild(MatStepper) stepper!: MatStepper;
public accessToken: string;
public authToken: string;
public isCreateAccountButtonDisabled = true;
public isDisclaimerChecked = false;
public role: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
public constructor(@Inject(MAT_DIALOG_DATA) public data: any) {}
public createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken, role }) => {
this.accessToken = accessToken;
this.authToken = authToken;
this.role = role;
this.stepper.next();
this.changeDetectorRef.markForCheck();
});
}
public enableCreateAccountButton() {
this.isCreateAccountButtonDisabled = false;
}
public enableAgreeButton() {
this.isAgreeButtonDisabled = false;
public onChangeDislaimerChecked() {
this.isDisclaimerChecked = !this.isDisclaimerChecked;
}
}

89
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.html

@ -1,48 +1,93 @@
<h1 mat-dialog-title>
<span i18n>Create Account</span>
@if (data.role === 'ADMIN') {
<span class="badge badge-light ml-2">{{ data.role }}</span>
@if (role === 'ADMIN') {
<span class="badge badge-light ml-2">{{ role }}</span>
}
</h1>
<div class="py-3" mat-dialog-content>
<div class="px-0" mat-dialog-content>
<mat-stepper #stepper animationDuration="0ms" linear>
<mat-step editable="false" [completed]="isDisclaimerChecked">
<ng-template i18n matStepLabel>Terms and Conditions</ng-template>
<div class="pt-2">
<ng-container i18n
>Please keep your security token safe. If you lose it, you will not be
able to recover your account.</ng-container
>
</div>
<mat-checkbox
class="pt-2"
color="primary"
(change)="onChangeDislaimerChecked()"
>
<ng-container i18n
>I understand that if I lose my security token, I cannot recover my
account.</ng-container
>
</mat-checkbox>
<div class="mt-3" mat-dialog-actions>
<div class="flex-grow-1">
<button i18n mat-button [mat-dialog-close]="undefined">Cancel</button>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<button
color="primary"
mat-flat-button
[disabled]="!isDisclaimerChecked"
(click)="createAccount()"
>
<ng-container i18n>Continue</ng-container>
</button>
</div>
</div>
</mat-step>
<mat-step editable="false">
<ng-template i18n matStepLabel>Security Token</ng-template>
<div class="pt-2">
<ng-container i18n
>Here is your security token. It is only visible once, please store
and keep it in a safe place.</ng-container
>
</div>
<mat-form-field appearance="outline" class="pt-3 w-100 without-hint">
<mat-label i18n>Security Token</mat-label>
<textarea
cdkTextareaAutosize
matInput
readonly
type="text"
[(value)]="data.accessToken"
[(value)]="accessToken"
></textarea>
<div class="float-right mt-3">
<div class="float-right mt-1">
<button
color="secondary"
mat-flat-button
[cdkCopyToClipboard]="data.accessToken"
(click)="enableAgreeButton()"
>
<ion-icon class="mr-1" name="copy-outline" /><span i18n
>Copy to clipboard</span
[cdkCopyToClipboard]="accessToken"
(click)="enableCreateAccountButton()"
>
<ion-icon class="mr-1" name="copy-outline" />
<span i18n>Copy to clipboard</span>
</button>
</div>
</mat-form-field>
</div>
<p i18n>
I agree to have stored my <i>Security Token</i> from above in a secure
place. If I lose it, I cannot get my account back.
</p>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button>
<div align="end" class="mt-1" mat-dialog-actions>
<div>
<button
color="primary"
mat-flat-button
[disabled]="isAgreeButtonDisabled"
[mat-dialog-close]="data"
matStepperNext
[disabled]="isCreateAccountButtonDisabled"
[mat-dialog-close]="authToken"
>
<span i18n>Agree and continue</span>
<span i18n>Create Account</span>
<ion-icon class="ml-1" name="arrow-forward-outline" />
</button>
</div>
</div>
</mat-step>
<ng-template matStepperIcon="done">
<ion-icon name="checkmark-outline"></ion-icon>
</ng-template>
</mat-stepper>
<div></div>
</div>

4
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.module.ts

@ -4,9 +4,11 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatStepperModule } from '@angular/material/stepper';
import { ShowAccessTokenDialog } from './show-access-token-dialog.component';
@ -17,9 +19,11 @@ import { ShowAccessTokenDialog } from './show-access-token-dialog.component';
CommonModule,
FormsModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatStepperModule,
ReactiveFormsModule,
TextFieldModule
],

4
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.scss

@ -1,2 +1,6 @@
:host {
.mat-mdc-dialog-actions {
padding-left: 0 !important;
padding-right: 0 !important;
}
}

3
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -3,14 +3,13 @@ import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { translate } from '@ghostfolio/ui/i18n';
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
imports: [MatButtonModule, RouterModule],
selector: 'gf-product-page',
styleUrls: ['./product-page.scss'],
templateUrl: './product-page.html'

20
apps/client/src/app/services/admin.service.ts

@ -1,8 +1,6 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
HEADER_KEY_SKIP_INTERCEPTOR,
@ -25,7 +23,7 @@ import {
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
import { DataSource, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull';
import { format } from 'date-fns';
import { switchMap } from 'rxjs';
@ -75,10 +73,6 @@ export class AdminService {
);
}
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tag/${aId}`);
}
public executeJob(aId: string) {
return this.http.get<void>(`/api/v1/admin/queue/job/${aId}/execute`);
}
@ -155,10 +149,6 @@ export class AdminService {
return this.http.get<Platform[]>('/api/v1/platform');
}
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tag');
}
public fetchUsers({
skip,
take = DEFAULT_PAGE_SIZE
@ -285,10 +275,6 @@ export class AdminService {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tag`, aTag);
}
public putPlatform(aPlatform: UpdatePlatformDto) {
return this.http.put<Platform>(
`/api/v1/platform/${aPlatform.id}`,
@ -296,10 +282,6 @@ export class AdminService {
);
}
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tag/${aTag.id}`, aTag);
}
public testMarketData({
dataSource,
scraperConfiguration,

49
apps/client/src/app/services/data.service.ts

@ -4,6 +4,8 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import {
Activities,
@ -44,7 +46,12 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { AccountWithValue, DateRange, GroupBy } from '@ghostfolio/common/types';
import type {
AccountWithValue,
AiPromptMode,
DateRange,
GroupBy
} from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { HttpClient, HttpParams } from '@angular/common/http';
@ -301,13 +308,17 @@ export class DataService {
}
public deleteBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
return this.http.delete<any>(`/api/v1/benchmarks/${dataSource}/${symbol}`);
}
public deleteOwnUser(aData: DeleteOwnUserDto) {
return this.http.delete<any>(`/api/v1/user`, { body: aData });
}
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tags/${aId}`);
}
public deleteUser(aId: string) {
return this.http.delete<any>(`/api/v1/user/${aId}`);
}
@ -332,21 +343,27 @@ export class DataService {
public fetchBenchmarkForUser({
dataSource,
filters,
range,
startDate,
symbol
symbol,
withExcludedAccounts
}: {
filters?: Filter[];
range: DateRange;
startDate: Date;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> {
let params = new HttpParams();
let params = this.buildFiltersAsQueryParams({ filters });
if (range) {
params = params.append('range', range);
if (withExcludedAccounts) {
params = params.append('withExcludedAccounts', withExcludedAccounts);
}
return this.http.get<BenchmarkMarketDataDetails>(
`/api/v1/benchmark/${dataSource}/${symbol}/${format(
`/api/v1/benchmarks/${dataSource}/${symbol}/${format(
startDate,
DATE_FORMAT
)}`,
@ -355,7 +372,7 @@ export class DataService {
}
public fetchBenchmarks() {
return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
return this.http.get<BenchmarkResponse>('/api/v1/benchmarks');
}
public fetchExport({
@ -649,8 +666,8 @@ export class DataService {
return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report');
}
public fetchPrompt() {
return this.http.get<AiPromptResponse>('/api/v1/ai/prompt');
public fetchPrompt(mode: AiPromptMode) {
return this.http.get<AiPromptResponse>(`/api/v1/ai/prompt/${mode}`);
}
public fetchPublicPortfolio(aAccessId: string) {
@ -673,6 +690,10 @@ export class DataService {
);
}
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tags');
}
public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken
@ -699,7 +720,7 @@ export class DataService {
}
public postBenchmark(benchmark: AssetProfileIdentifier) {
return this.http.post('/api/v1/benchmark', benchmark);
return this.http.post('/api/v1/benchmarks', benchmark);
}
public postMarketData({
@ -720,6 +741,10 @@ export class DataService {
return this.http.post<OrderModel>('/api/v1/order', aOrder);
}
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tags`, aTag);
}
public postUser() {
return this.http.post<UserItem>('/api/v1/user', {});
}
@ -747,6 +772,10 @@ export class DataService {
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
}
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tags/${aTag.id}`, aTag);
}
public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>('/api/v1/user/setting', aData);
}

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

Loading…
Cancel
Save