Browse Source

fix: remove invalid { each: true } from @IsEnum decorators on scalar DTO properties

pull/7164/head
AkashNegi1 2 weeks ago
parent
commit
c424570643
  1. 1
      .gitignore
  2. 102
      CHANGELOG.md
  3. 2
      README.md
  4. 4
      apps/api/jest.config.ts
  5. 10
      apps/api/src/app/access/access.controller.ts
  6. 7
      apps/api/src/app/access/access.service.ts
  7. 8
      apps/api/src/app/activities/activities.service.ts
  8. 4
      apps/api/src/app/admin/admin.controller.ts
  9. 12
      apps/api/src/app/admin/admin.service.ts
  10. 2
      apps/api/src/app/admin/queue/queue.service.ts
  11. 8
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  12. 9
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  13. 22
      apps/api/src/app/endpoints/public/public.controller.ts
  14. 7
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  15. 15
      apps/api/src/app/import/import.service.ts
  16. 2
      apps/api/src/app/info/info.service.ts
  17. 15
      apps/api/src/app/portfolio/portfolio.service.ts
  18. 12
      apps/api/src/app/symbol/symbol.service.ts
  19. 6
      apps/api/src/helper/country.helper.ts
  20. 67
      apps/api/src/helper/data-source.helper.ts
  21. 2
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  22. 2
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  23. 26
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  24. 5
      apps/api/src/services/configuration/configuration.service.ts
  25. 47
      apps/api/src/services/cron/cron.module.ts
  26. 11
      apps/api/src/services/cron/cron.service.ts
  27. 18
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  28. 89
      apps/api/src/services/data-provider/data-provider.service.ts
  29. 6
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  30. 1
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  31. 45
      apps/api/src/services/data-provider/manual/manual.service.ts
  32. 19
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  33. 18
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  34. 69
      apps/api/src/services/fetch/fetch.service.ts
  35. 19
      apps/api/src/services/fetch/interfaces/proxy-route.interface.ts
  36. 2
      apps/api/src/services/interfaces/environment.interface.ts
  37. 74
      apps/api/src/services/market-data/market-data.service.ts
  38. 3
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  39. 21
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  40. 180
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  41. 37
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  42. 4
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  43. 12
      apps/client/project.json
  44. 4
      apps/client/src/app/components/access-table/access-table.component.html
  45. 10
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  46. 2
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  47. 22
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  48. 5
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  49. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  50. 7
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  51. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  52. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  53. 30
      apps/client/src/app/components/admin-settings/admin-settings.component.scss
  54. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  55. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  56. 7
      apps/client/src/app/components/footer/footer.component.html
  57. 41
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  58. 4
      apps/client/src/app/components/home-market/home-market.component.ts
  59. 79
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  60. 12
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  61. 1
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  62. 3
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  63. 17
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  64. 3
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  65. 7
      apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts
  66. 78
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  67. 6
      apps/client/src/app/pages/api/api-page.component.ts
  68. 8
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  69. 11
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  70. 333
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  71. 2
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  72. 6
      apps/client/src/app/pages/portfolio/allocations/interfaces/interfaces.ts
  73. 132
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  74. 116
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.html
  75. 296
      apps/client/src/locales/messages.ca.xlf
  76. 310
      apps/client/src/locales/messages.de.xlf
  77. 296
      apps/client/src/locales/messages.es.xlf
  78. 296
      apps/client/src/locales/messages.fr.xlf
  79. 296
      apps/client/src/locales/messages.it.xlf
  80. 8789
      apps/client/src/locales/messages.ja.xlf
  81. 296
      apps/client/src/locales/messages.ko.xlf
  82. 296
      apps/client/src/locales/messages.nl.xlf
  83. 296
      apps/client/src/locales/messages.pl.xlf
  84. 296
      apps/client/src/locales/messages.pt.xlf
  85. 296
      apps/client/src/locales/messages.tr.xlf
  86. 296
      apps/client/src/locales/messages.uk.xlf
  87. 265
      apps/client/src/locales/messages.xlf
  88. 296
      apps/client/src/locales/messages.zh.xlf
  89. 13
      libs/common/src/lib/config.ts
  90. 8
      libs/common/src/lib/dtos/create-access.dto.ts
  91. 4
      libs/common/src/lib/dtos/create-asset-profile.dto.ts
  92. 6
      libs/common/src/lib/dtos/create-order.dto.ts
  93. 8
      libs/common/src/lib/dtos/update-access.dto.ts
  94. 4
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  95. 4
      libs/common/src/lib/dtos/update-order.dto.ts
  96. 22
      libs/common/src/lib/helper.ts
  97. 5
      libs/common/src/lib/interfaces/access-settings.interface.ts
  98. 3
      libs/common/src/lib/interfaces/access.interface.ts
  99. 2
      libs/common/src/lib/interfaces/holding.interface.ts
  100. 2
      libs/common/src/lib/interfaces/index.ts

1
.gitignore

@ -32,7 +32,6 @@ npm-debug.log
.env.prod
.github/instructions/nx.instructions.md
.nx/cache
.nx/migrate-runs
.nx/polygraph
.nx/self-healing
.nx/workspace-data

102
CHANGELOG.md

@ -5,107 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added support for routing outgoing requests through a per-domain proxy via the `PROXY_ROUTES` setting in the `FetchService`
## 3.18.0 - 2026-06-28
### Added
- Added support for filtering in the public access for portfolio sharing (experimental)
- Set up the language localization for Japanese (`ja`)
### Changed
- Improved the alias display in the access table to share the portfolio
- Improved the language localization for German (`de`)
### Fixed
- Fixed a phantom `UNKNOWN` slice in the portfolio proportion chart component caused by floating-point rounding
- Fixed the base currency for the total value calculation in the public access for portfolio sharing
- Fixed an issue in the public access for portfolio sharing that exposed absolute values of the top holdings of ETFs
- Fixed the time zone handling in the `api` test suite for deterministic execution in `UTC`
## 3.17.0 - 2026-06-26
### Added
- Added `zod` as a root dependency to resolve peer dependency warnings
### Changed
- Improved the error message styling in the import activities dialog
- Improved the grantee display in the access table to share the portfolio
- Improved the country mapping for data providers
- Upgraded `bull-board` from version `7.2.1` to `8.0.1`
- Upgraded `Nx` from version `22.7.5` to `23.0.1`
- Upgraded `prettier` from version `3.8.3` to `3.8.4`
### Fixed
- Improved the table headers’ alignment in the queue jobs table of the admin control panel
## 3.16.0 - 2026-06-24
### Added
- Extended the user account settings with a copy-to-clipboard button for the user id
- Added pagination to the platform management of the admin control panel
- Added pagination to the tag management of the admin control panel
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the ISIN number
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the symbol
### Changed
- Improved the throughput of the market data gathering queue by applying the rate limit per data source
- Decreased the rate limiter duration of the market data gathering queue jobs from 4 to 3 seconds
- Removed the deprecated `SymbolProfile` field from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.2.2` to `13.3`
### Fixed
- Fixed an issue with hourly market data updates not refreshing prices for asset profiles with `MANUAL` data source
- Fixed an issue with the log context formatting in the performance logging service
## 3.15.1 - 2026-06-23
### Changed
- Improved the dynamic numerical precision for various values in the account detail dialog on mobile
- Improved the dynamic numerical precision for various values in the holding detail dialog on mobile
- Upgraded `@internationalized/number` from version `3.6.6` to `3.6.7`
### Fixed
- Fixed an issue where symbols with special characters caused API request failures by URL encoding the symbol
- Fixed the disabled state of the delete action in the asset profiles actions menu of the historical market data table in the admin control panel
- Fixed the persistence of an empty `locale` string in the scraper configuration
- Fixed a transaction timeout that prevented gathering historical market data for symbols with a long history
- Fixed an exception in various portfolio endpoints when historical exchange rate data is missing
## 3.14.0 - 2026-06-22
### Added
- Exposed the `ENABLE_FEATURE_CRON` environment variable to control scheduled cron job execution
- Exposed the `PROCESSOR_GATHER_STATISTICS_CONCURRENCY` environment variable to control the concurrency of the statistics gathering queue processor
### Changed
- Consolidated the exchange rates to be gathered with hourly market data
- Improved the language localization for German (`de`)
- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.0` to `2.9.1`
- Upgraded `undici` from version `7.24.4` to `8.5.0`
### Fixed
- Fixed an issue in the data provider service where asset profiles and historical data could be missing for symbols that exist in multiple data sources by keying the responses by the asset profile identifier
- Resolved an exception in the benchmarks service when the current market price is unavailable
## 3.13.0 - 2026-06-20
### Added
@ -116,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Removed the deprecated `SymbolProfile` field from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Changed the _Fear & Greed Index_ (market mood) in the markets overview to use the stored market data instead of a live quote
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol`

2
README.md

@ -107,7 +107,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### OpenID Connect OIDC (experimental)
#### OpenID Connect OIDC (Experimental)
| Name | Type | Default Value | Description |
| -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |

4
apps/api/jest.config.ts

@ -1,8 +1,4 @@
/* eslint-disable */
// Run tests in UTC for deterministic date-based calculations
process.env.TZ = 'UTC';
export default {
displayName: 'api',

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

@ -3,7 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { Access, AccessSettings } from '@ghostfolio/common/interfaces';
import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -46,14 +46,13 @@ export class AccessController {
});
return accessesWithGranteeUser.map(
({ alias, granteeUser, id, permissions, settings }) => {
({ alias, granteeUser, id, permissions }) => {
if (granteeUser) {
return {
alias,
id,
permissions,
grantee: granteeUser?.id,
settings: settings as AccessSettings,
type: 'PRIVATE'
};
}
@ -63,7 +62,6 @@ export class AccessController {
id,
permissions,
grantee: 'Public',
settings: settings as AccessSettings,
type: 'PUBLIC'
};
}
@ -93,7 +91,6 @@ export class AccessController {
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } }
});
} catch {
@ -161,8 +158,7 @@ export class AccessController {
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions,
settings: this.accessService.buildSettings(data.filters)
permissions: data.permissions
},
where: { id }
});

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

@ -1,5 +1,4 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccessSettings, Filter } from '@ghostfolio/common/interfaces';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -40,12 +39,6 @@ export class AccessService {
});
}
public buildSettings(filters?: Filter[]) {
const settings: AccessSettings = filters?.length ? { filters } : {};
return settings as Prisma.InputJsonValue;
}
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
return this.prismaService.access.create({
data

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

@ -746,10 +746,10 @@ export class ActivitiesService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [
feeInAssetProfileCurrency = 0,
feeInBaseCurrency = 0,
unitPriceInAssetProfileCurrency = 0,
valueInBaseCurrency = 0
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
valueInBaseCurrency
] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,

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

@ -86,8 +86,8 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl)
@Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherRecentMarketData(): Promise<void> {
this.dataGatheringService.gatherRecentMarketData();
public async gather7Days(): Promise<void> {
this.dataGatheringService.gather7Days();
}
@HasPermission(permissions.accessAdminControl)

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

@ -11,10 +11,7 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
getAssetProfileIdentifier,
getCurrencyFromSymbol
} from '@ghostfolio/common/helper';
import { getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import {
AdminData,
AdminUserResponse,
@ -71,17 +68,14 @@ export class AdminService {
{ dataSource, symbol }
]);
const assetProfile =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
return this.symbolProfileService.add(
assetProfile as Prisma.SymbolProfileCreateInput
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
} catch (error) {
if (

2
apps/api/src/app/admin/queue/queue.service.ts

@ -56,7 +56,7 @@ export class QueueService {
}
public async getJobs({
limit = 5000,
limit = 1000,
status = QUEUE_JOB_STATUS_LIST
}: {
limit?: number;

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

@ -81,14 +81,6 @@ export class BenchmarksService {
})
]);
if (!currentSymbolItem) {
this.logger.error(
`No current market price is available for ${symbol} (${dataSource})`
);
return { marketData };
}
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,

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

@ -16,7 +16,6 @@ import {
DERIVED_CURRENCIES
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
@ -61,13 +60,7 @@ export class GhostfolioService {
}
])
.then(async (assetProfiles) => {
const assetProfile =
assetProfiles[
getAssetProfileIdentifier({
symbol,
dataSource: dataProviderService.getName()
})
];
const assetProfile = assetProfiles[symbol];
const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) {

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

@ -9,18 +9,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { getSum } from '@ghostfolio/common/helper';
import {
AccessSettings,
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import {
AssetClass,
AssetSubClass,
@ -37,6 +37,7 @@ export class PublicController {
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -65,8 +66,6 @@ export class PublicController {
hasDetails = user.subscription.type === SubscriptionType.Premium;
}
const { filters } = (access.settings ?? {}) as AccessSettings;
const [
{ createdAt, holdings, markets },
{ performance: performance1d },
@ -74,7 +73,6 @@ export class PublicController {
{ performance: performanceYtd }
] = await Promise.all([
this.portfolioService.getDetails({
filters,
impersonationId: access.userId,
userId: user.id,
withMarkets: true
@ -82,7 +80,6 @@ export class PublicController {
...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId: undefined,
userId: user.id
});
@ -90,7 +87,6 @@ export class PublicController {
]);
const { activities } = await this.activitiesService.getActivities({
filters,
sortColumn: 'date',
sortDirection: 'desc',
take: 10,
@ -164,7 +160,8 @@ export class PublicController {
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
assetProfile.currency,
user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
this.request.user?.settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
)
);
})
@ -196,11 +193,6 @@ export class PublicController {
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel
: undefined,
holdings: portfolioPosition.assetProfile.holdings?.map(
({ allocationInPercentage, name }) => {
return { allocationInPercentage, name };
}
),
...(hasDetails
? {}
: {

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

@ -40,17 +40,14 @@ export class WatchlistService {
{ dataSource, symbol }
]);
const assetProfile =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
await this.symbolProfileService.add(
assetProfile as Prisma.SymbolProfileCreateInput
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
}

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

@ -592,19 +592,18 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency =
(await this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
)) ?? 0;
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
);
activities.push({
...order,
error,
value,
valueInBaseCurrency,
valueInBaseCurrency: await valueInBaseCurrency,
// @ts-ignore
SymbolProfile: assetProfile
});

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

@ -1,7 +1,6 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -18,6 +17,7 @@ import {
PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';

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

@ -205,21 +205,21 @@ export class PortfolioService {
switch (type) {
case ActivityType.DIVIDEND:
dividendInBaseCurrency +=
(await this.exchangeRateDataService.toCurrencyAtDate(
await this.exchangeRateDataService.toCurrencyAtDate(
new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency,
userCurrency,
date
)) ?? 0;
);
break;
case ActivityType.INTEREST:
interestInBaseCurrency +=
(await this.exchangeRateDataService.toCurrencyAtDate(
await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
currency ?? SymbolProfile.currency,
userCurrency,
date
)) ?? 0;
);
break;
}
@ -889,13 +889,10 @@ export class PortfolioService {
marketPrice
);
const historicalDataItems =
historicalData[getAssetProfileIdentifier({ dataSource, symbol })];
if (historicalDataItems) {
if (historicalData[symbol]) {
let j = -1;
for (const [date, { marketPrice }] of Object.entries(
historicalDataItems
historicalData[symbol]
)) {
while (
j + 1 < transactionPoints.length &&

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

@ -94,17 +94,12 @@ export class SymbolService {
date = new Date(),
symbol
}: DataGatheringItem): Promise<DataProviderHistoricalResponse> {
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
let historicalData: {
[assetProfileIdentifier: string]: {
[symbol: string]: {
[date: string]: DataProviderHistoricalResponse;
};
} = {
[assetProfileIdentifier]: {}
[symbol]: {}
};
try {
@ -117,8 +112,7 @@ export class SymbolService {
return {
marketPrice:
historicalData?.[assetProfileIdentifier]?.[format(date, DATE_FORMAT)]
?.marketPrice
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
};
}

6
apps/api/src/helper/country.helper.ts

@ -7,12 +7,8 @@ export function getCountryCodeByName({
aliases?: Record<string, string>;
name: string;
}): string {
if (aliases[name]) {
return aliases[name];
}
for (const [code, country] of Object.entries(countries)) {
if (country.name === name) {
if (country.name === name || country.name === aliases[name]) {
return code;
}
}

67
apps/api/src/helper/data-source.helper.ts

@ -1,67 +0,0 @@
import { DataSource } from '@prisma/client';
import { createHash } from 'node:crypto';
const encodedDataSourceByDataSource = new Map<DataSource, string>(
Object.values(DataSource).map((dataSource) => {
return [dataSource, hashDataSource(dataSource)];
})
);
const dataSourceByEncodedDataSource = new Map<string, DataSource>(
[...encodedDataSourceByDataSource].map(([dataSource, encodedDataSource]) => {
return [encodedDataSource, dataSource];
})
);
/**
* @deprecated Backward compatibility to support importing data that was
* exported using the previous data source encoding
*/
const dataSourceByDeprecatedEncodedDataSource = new Map<string, DataSource>(
Object.values(DataSource).map((dataSource) => {
return [deprecatedHashDataSource(dataSource), dataSource];
})
);
/**
* @deprecated Backward compatibility (see above)
*/
function deprecatedHashDataSource(dataSource: DataSource) {
return Buffer.from(dataSource, 'utf-8').toString('hex');
}
function hashDataSource(dataSource: DataSource) {
return createHash('sha256').update(dataSource).digest('hex').slice(0, 8);
}
export function decodeDataSource(encodedDataSource: string) {
if (!encodedDataSource) {
return undefined;
}
return (
dataSourceByEncodedDataSource.get(encodedDataSource) ??
dataSourceByDeprecatedEncodedDataSource.get(encodedDataSource) ??
encodedDataSource
);
}
export function encodeDataSource(dataSource: DataSource) {
if (!dataSource) {
return undefined;
}
return encodedDataSourceByDataSource.get(dataSource);
}
export function getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
}: {
dataSource: DataSource;
ghostfolioDataSources: string[];
}) {
return ghostfolioDataSources.includes(dataSource)
? DataSource.GHOSTFOLIO
: dataSource;
}

2
apps/api/src/interceptors/performance-logging/performance-logging.service.ts

@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class PerformanceLoggingService {
private readonly logger = new Logger();
private readonly logger = new Logger(PerformanceLoggingService.name);
public logPerformance({
className,

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

@ -1,5 +1,5 @@
import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { decodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,

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

@ -1,10 +1,6 @@
import {
encodeDataSource,
getMaskedGhostfolioDataSource
} from '@ghostfolio/api/helper/data-source.helper';
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { hasRole } from '@ghostfolio/common/permissions';
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
@ -48,32 +44,20 @@ export class TransformDataSourceInResponseInterceptor<
next: CallHandler<T>
): Observable<any> {
const isExportMode = context.getClass().name === 'ExportController';
const { user } = context.switchToHttp().getRequest();
return next.handle().pipe(
map((data: any) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const valueMap = hasRole(user, 'ADMIN')
? {}
: { ...this.encodedDataSourceMap };
const valueMap = this.encodedDataSourceMap;
if (isExportMode) {
const ghostfolioDataSources = this.configurationService.get(
for (const dataSource of this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
) as DataSource[];
for (const dataSource of ghostfolioDataSources) {
valueMap[dataSource] = getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
});
)) {
valueMap[dataSource] = 'GHOSTFOLIO';
}
}
if (Object.keys(valueMap).length === 0) {
return data;
}
data = redactPaths({
valueMap,
object: data,

5
apps/api/src/services/configuration/configuration.service.ts

@ -6,7 +6,6 @@ import {
DEFAULT_PORT,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT
} from '@ghostfolio/common/config';
@ -44,7 +43,6 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
@ -90,9 +88,6 @@ export class ConfigurationService {
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY
}),
PROCESSOR_GATHER_STATISTICS_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY
}),
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY
}),

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

@ -1,17 +1,12 @@
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Logger, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { CronService } from './cron.service';
@ -19,46 +14,12 @@ import { CronService } from './cron.service';
imports: [
ConfigurationModule,
DataGatheringQueueModule,
ExchangeRateDataModule,
PropertyModule,
StatisticsGatheringQueueModule,
TwitterBotModule,
UserModule
],
providers: [
{
inject: [
ConfigurationService,
DataGatheringService,
PropertyService,
StatisticsGatheringService,
TwitterBotService,
UserService
],
provide: CronService,
useFactory: (
configurationService: ConfigurationService,
dataGatheringService: DataGatheringService,
propertyService: PropertyService,
statisticsGatheringService: StatisticsGatheringService,
twitterBotService: TwitterBotService,
userService: UserService
) => {
if (!configurationService.get('ENABLE_FEATURE_CRON')) {
Logger.log('Scheduled cron jobs are disabled', 'CronService');
return null;
}
return new CronService(
configurationService,
dataGatheringService,
propertyService,
statisticsGatheringService,
twitterBotService,
userService
);
}
}
]
providers: [CronService]
})
export class CronModule {}

11
apps/api/src/services/cron/cron.service.ts

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service';
@ -23,6 +24,7 @@ export class CronService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService,
private readonly statisticsGatheringService: StatisticsGatheringService,
private readonly twitterBotService: TwitterBotService,
@ -39,11 +41,16 @@ export class CronService {
@Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE)
public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) {
await this.dataGatheringService.gatherHourlyMarketData();
await this.dataGatheringService.gatherRecentMarketData();
await this.dataGatheringService.gather7Days();
await this.dataGatheringService.gatherHourlySymbols();
}
}
@Cron(CronExpression.EVERY_12_HOURS)
public async runEveryTwelveHours() {
await this.exchangeRateDataService.loadCurrencies();
}
@Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePm() {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {

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

@ -13,18 +13,12 @@ import { SymbolProfile } from '@prisma/client';
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com';
private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countriesMapping = {
'Republic of Korea': 'KR',
'Russian Federation': 'RU',
Turkey: 'TR',
USA: 'US',
'Virgin Islands, British': 'VG'
'Russian Federation': 'Russia',
USA: 'United States'
};
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping: Record<string, SectorName> = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Staples': 'Consumer Defensive',
@ -77,7 +71,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
const profile = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${trackinsightSymbol}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
@ -101,7 +95,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
const holdings = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/holdings/${trackinsightSymbol}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
@ -194,7 +188,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}) {
return this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{
signal: AbortSignal.timeout(requestTimeout)
}

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

@ -1,6 +1,5 @@
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getMaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -87,11 +86,12 @@ export class DataProviderService implements OnModuleInit {
return false;
}
// TODO: Change symbol in response to assetProfileIdentifier
public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => {
@ -117,12 +117,7 @@ export class DataProviderService implements OnModuleInit {
promises.push(
promise.then((assetProfile) => {
if (isCurrency(assetProfile?.currency)) {
response[
getAssetProfileIdentifier({
symbol,
dataSource: DataSource[dataSource]
})
] = { ...assetProfile, symbol };
response[symbol] = assetProfile;
}
})
);
@ -222,9 +217,6 @@ export class DataProviderService implements OnModuleInit {
} = {};
const dataSources = await this.getDataSources();
const ghostfolioDataSources = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
);
for (const [
index,
@ -232,10 +224,6 @@ export class DataProviderService implements OnModuleInit {
] of activitiesDto.entries()) {
const activityPath =
maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`;
const maskedDataSource = getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
});
if (!dataSources.includes(dataSource)) {
throw new Error(
@ -251,7 +239,7 @@ export class DataProviderService implements OnModuleInit {
if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error(
`${activityPath}.dataSource ("${maskedDataSource}") requires Ghostfolio Premium`
`${activityPath}.dataSource ("${dataSource}") is not valid`
);
}
}
@ -295,7 +283,7 @@ export class DataProviderService implements OnModuleInit {
symbol
}
])
)?.[assetProfileIdentifier];
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
@ -314,7 +302,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
@ -345,20 +333,17 @@ export class DataProviderService implements OnModuleInit {
});
}
// TODO: Change symbol in response to assetProfileIdentifier
public async getHistorical(
aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month',
from: Date,
to: Date
): Promise<{
[assetProfileIdentifier: string]: {
[date: string]: DataProviderHistoricalResponse;
};
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}> {
let response: {
[assetProfileIdentifier: string]: {
[date: string]: DataProviderHistoricalResponse;
};
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
} = {};
if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
@ -398,20 +383,13 @@ export class DataProviderService implements OnModuleInit {
ORDER BY date;`;
response = marketDataByGranularity.reduce((r, marketData) => {
const { dataSource, date, marketPrice, symbol } = marketData;
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
const { date, marketPrice, symbol } = marketData;
if (!r[assetProfileIdentifier]) {
r[assetProfileIdentifier] = {};
if (!r[symbol]) {
r[symbol] = {};
}
r[assetProfileIdentifier][format(new Date(date), DATE_FORMAT)] = {
marketPrice
};
r[symbol][format(new Date(date), DATE_FORMAT)] = { marketPrice };
return r;
}, {});
@ -422,6 +400,7 @@ export class DataProviderService implements OnModuleInit {
}
}
// TODO: Change symbol in response to assetProfileIdentifier
public async getHistoricalRaw({
assetProfileIdentifiers,
from,
@ -431,9 +410,7 @@ export class DataProviderService implements OnModuleInit {
from: Date;
to: Date;
}): Promise<{
[assetProfileIdentifier: string]: {
[date: string]: DataProviderHistoricalResponse;
};
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}> {
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
if (
@ -466,14 +443,11 @@ export class DataProviderService implements OnModuleInit {
);
const result: {
[assetProfileIdentifier: string]: {
[date: string]: DataProviderHistoricalResponse;
};
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
} = {};
const promises: Promise<{
data: { [date: string]: DataProviderHistoricalResponse };
dataSource: DataSource;
symbol: string;
}>[] = [];
for (const { dataSource, symbol } of assetProfileIdentifiers) {
@ -491,7 +465,6 @@ export class DataProviderService implements OnModuleInit {
promises.push(
Promise.resolve({
data,
dataSource,
symbol
})
);
@ -505,7 +478,7 @@ export class DataProviderService implements OnModuleInit {
requestTimeout: ms('30 seconds')
})
.then((data) => {
return { dataSource, symbol, data: data?.[symbol] };
return { symbol, data: data?.[symbol] };
})
);
}
@ -515,26 +488,22 @@ export class DataProviderService implements OnModuleInit {
try {
const allData = await Promise.all(promises);
for (const { data, dataSource, symbol } of allData) {
for (const { data, symbol } of allData) {
const currency = DERIVED_CURRENCIES.find(({ rootCurrency }) => {
return `${DEFAULT_CURRENCY}${rootCurrency}` === symbol;
});
if (currency) {
// Add derived currency
result[
getAssetProfileIdentifier({
dataSource,
symbol: `${DEFAULT_CURRENCY}${currency.currency}`
})
] = this.transformHistoricalData({
allData,
currency: `${DEFAULT_CURRENCY}${currency.rootCurrency}`,
factor: currency.factor
});
result[`${DEFAULT_CURRENCY}${currency.currency}`] =
this.transformHistoricalData({
allData,
currency: `${DEFAULT_CURRENCY}${currency.rootCurrency}`,
factor: currency.factor
});
}
result[getAssetProfileIdentifier({ dataSource, symbol })] = data;
result[symbol] = data;
}
} catch (error) {
this.logger.error(error);
@ -670,11 +639,7 @@ export class DataProviderService implements OnModuleInit {
);
const promise = Promise.resolve(
dataProvider.getQuotes({
requestTimeout,
useCache,
symbols: symbolsChunk
})
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
);
promises.push(

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

@ -54,9 +54,9 @@ export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = {
'Korea (the Republic of)': 'KR',
'Russian Federation': 'RU',
'Taiwan (Province of China)': 'TW'
'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'Russia',
'Taiwan (Province of China)': 'Taiwan'
};
private readonly logger = new Logger(FinancialModelingPrepService.name);

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

@ -75,7 +75,6 @@ export interface GetHistoricalParams {
export interface GetQuotesParams {
requestTimeout?: number;
symbols: string[];
useCache?: boolean;
}
export interface GetSearchParams {

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

@ -136,8 +136,7 @@ export class ManualService implements DataProviderInterface {
}
public async getQuotes({
symbols,
useCache = true
symbols
}: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> {
const response: { [symbol: string]: DataProviderResponse } = {};
@ -165,32 +164,32 @@ export class ManualService implements DataProviderInterface {
}
});
const symbolProfilesToScrape = symbolProfiles.filter(
({ scraperConfiguration }) => {
const symbolProfilesWithScraperConfigurationAndInstantMode =
symbolProfiles.filter(({ scraperConfiguration }) => {
return (
(scraperConfiguration?.mode === 'instant' || !useCache) &&
scraperConfiguration?.mode === 'instant' &&
scraperConfiguration?.selector &&
scraperConfiguration?.url
);
}
);
const scraperResultPromises = symbolProfilesToScrape.map(
async ({ scraperConfiguration, symbol }) => {
try {
const marketPrice = await this.scrape({
scraperConfiguration,
symbol
});
return { marketPrice, symbol };
} catch (error) {
this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
);
return { symbol, marketPrice: undefined };
});
const scraperResultPromises =
symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => {
try {
const marketPrice = await this.scrape({
scraperConfiguration,
symbol
});
return { marketPrice, symbol };
} catch (error) {
this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
);
return { symbol, marketPrice: undefined };
}
}
}
);
);
// Wait for all scraping requests to complete concurrently
const scraperResults = await Promise.all(scraperResultPromises);

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

@ -8,7 +8,10 @@ import {
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config';
import {
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import {
DataProviderHistoricalResponse,
@ -61,7 +64,12 @@ export class RapidApiService implements DataProviderInterface {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}> {
try {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
if (
[
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks
].includes(symbol)
) {
const fgi = await this.getFearAndGreedIndex();
return {
@ -98,7 +106,12 @@ export class RapidApiService implements DataProviderInterface {
try {
const symbol = symbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
if (
[
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks
].includes(symbol)
) {
const fgi = await this.getFearAndGreedIndex();
return {

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

@ -15,7 +15,6 @@ import {
getYesterday,
resetHours
} from '@ghostfolio/common/helper';
import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -164,7 +163,7 @@ export class ExchangeRateDataService {
}
public async loadCurrencies() {
const historicalData = await this.dataProviderService.getHistorical(
const result = await this.dataProviderService.getHistorical(
this.currencyPairs,
'day',
getYesterday(),
@ -178,21 +177,8 @@ export class ExchangeRateDataService {
requestTimeout: ms('30 seconds')
});
const result: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
} = {};
for (const { dataSource, symbol } of this.currencyPairs) {
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
if (historicalData[assetProfileIdentifier]) {
result[symbol] = historicalData[assetProfileIdentifier];
}
const quote = quotes[assetProfileIdentifier];
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (isNumber(quote?.marketPrice)) {
result[symbol] = {

69
apps/api/src/services/fetch/fetch.service.ts

@ -4,7 +4,6 @@ import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL,
PROPERTY_OPENROUTER_MODEL_WEB_FETCH,
PROPERTY_PROXY_ROUTES,
PROPERTY_WEB_FETCH_ROUTES
} from '@ghostfolio/common/config';
@ -13,7 +12,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText, jsonSchema, tool } from 'ai';
import ms from 'ms';
import { ProxyRoute } from './interfaces/proxy-route.interface';
import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable()
@ -23,17 +21,11 @@ export class FetchService implements OnModuleInit {
private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token'];
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds');
private proxyRoutes: ProxyRoute[] = [];
private webFetchRoutes: WebFetchRoute[] = [];
public constructor(private readonly propertyService: PropertyService) {}
public async onModuleInit() {
this.proxyRoutes =
(await this.propertyService.getByKey<ProxyRoute[]>(
PROPERTY_PROXY_ROUTES
)) ?? [];
this.webFetchRoutes =
(await this.propertyService.getByKey<WebFetchRoute[]>(
PROPERTY_WEB_FETCH_ROUTES
@ -67,10 +59,8 @@ export class FetchService implements OnModuleInit {
}
}
const proxiedInput = this.applyProxyRoute(input);
try {
return await globalThis.fetch(proxiedInput, init);
return await globalThis.fetch(input, init);
} catch (error) {
if (error instanceof Error) {
this.logger.error(
@ -181,73 +171,18 @@ export class FetchService implements OnModuleInit {
}
}
/**
* Rewrites the origin (protocol, host and port) of a request when its domain
* matches a configured {@link ProxyRoute}, preserving path and query. Returns
* the input unchanged when no route matches or parsing fails.
*/
private applyProxyRoute(input: RequestInfo | URL): RequestInfo | URL {
let requestUrl: URL;
try {
requestUrl = new URL(
input instanceof Request ? input.url : input.toString()
);
} catch {
return input;
}
const route = this.proxyRoutes.find(({ domain }) => {
return this.hostnameMatchesDomain({
domain,
hostname: requestUrl.hostname
});
});
if (!route) {
return input;
}
try {
const proxyUrl = new URL(route.url);
requestUrl.host = proxyUrl.host;
requestUrl.protocol = proxyUrl.protocol;
} catch {
this.logger.warn(
`Skipping proxy route for "${route.domain}": invalid url "${route.url}"`
);
return input;
}
return input instanceof Request
? new Request(requestUrl.toString(), input)
: requestUrl.toString();
}
private getMatchingWebFetchRoute(url: string) {
try {
const { hostname } = new URL(url);
return this.webFetchRoutes.find(({ domain }) => {
return this.hostnameMatchesDomain({ domain, hostname });
return hostname === domain || hostname.endsWith(`.${domain}`);
});
} catch {
return undefined;
}
}
private hostnameMatchesDomain({
domain,
hostname
}: {
domain: string;
hostname: string;
}): boolean {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
private redactUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);

19
apps/api/src/services/fetch/interfaces/proxy-route.interface.ts

@ -1,19 +0,0 @@
/**
* Overrides the origin (protocol, host and port) of outgoing requests for a
* given domain.
*
* Configured via the `PROXY_ROUTES` property as a JSON array, e.g.
*
* [
* {
* "domain": "example.com",
* "url": "http://example-proxy:8191"
* }
* ]
*
* Matches the domain itself and its subdomains (e.g. `api.example.com`).
*/
export interface ProxyRoute {
domain: string;
url: string;
}

2
apps/api/src/services/interfaces/environment.interface.ts

@ -19,7 +19,6 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_CRON: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
@ -45,7 +44,6 @@ export interface Environment extends CleanedEnvAccessors {
PORT: number;
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number;
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;
PROCESSOR_GATHER_STATISTICS_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number;
REDIS_DB: number;

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

@ -1,7 +1,6 @@
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT } from '@ghostfolio/common/config';
import { UpdateMarketDataDto } from '@ghostfolio/common/dtos';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -156,52 +155,49 @@ export class MarketDataService {
dataSource,
symbol
}: AssetProfileIdentifier & { data: Prisma.MarketDataUpdateInput[] }) {
await this.prismaService.$transaction(
async (prisma) => {
if (data.length > 0) {
let minTime = Infinity;
let maxTime = -Infinity;
await this.prismaService.$transaction(async (prisma) => {
if (data.length > 0) {
let minTime = Infinity;
let maxTime = -Infinity;
for (const { date } of data) {
const time = (date as Date).getTime();
for (const { date } of data) {
const time = (date as Date).getTime();
if (time < minTime) {
minTime = time;
}
if (time < minTime) {
minTime = time;
}
if (time > maxTime) {
maxTime = time;
}
if (time > maxTime) {
maxTime = time;
}
}
const minDate = new Date(minTime);
const maxDate = new Date(maxTime);
const minDate = new Date(minTime);
const maxDate = new Date(maxTime);
await prisma.marketData.deleteMany({
where: {
dataSource,
symbol,
date: {
gte: minDate,
lte: maxDate
}
await prisma.marketData.deleteMany({
where: {
dataSource,
symbol,
date: {
gte: minDate,
lte: maxDate
}
});
}
});
await prisma.marketData.createMany({
data: data.map(({ date, marketPrice, state }) => ({
dataSource,
symbol,
date: date as Date,
marketPrice: marketPrice as number,
state: state as MarketDataState
})),
skipDuplicates: true
});
}
},
{ timeout: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT }
);
await prisma.marketData.createMany({
data: data.map(({ date, marketPrice, state }) => ({
dataSource,
symbol,
date: date as Date,
marketPrice: marketPrice as number,
state: state as MarketDataState
})),
skipDuplicates: true
});
}
});
}
public async updateAssetProfileIdentifier(

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

@ -28,8 +28,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
}),
BullModule.registerQueue({
limiter: {
duration: ms('3 seconds'),
groupKey: 'dataSource',
duration: ms('4 seconds'),
max: 1
},
name: DATA_GATHERING_QUEUE

21
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -10,11 +10,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getStartOfUtcDate
} from '@ghostfolio/common/helper';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
@ -118,11 +114,6 @@ export class DataGatheringProcessor {
to: new Date()
});
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number;
@ -140,14 +131,12 @@ export class DataGatheringProcessor {
)
) {
if (
historicalData[assetProfileIdentifier]?.[
format(currentDate, DATE_FORMAT)
]?.marketPrice
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[assetProfileIdentifier]?.[
format(currentDate, DATE_FORMAT)
]?.marketPrice;
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
if (lastMarketPrice) {

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

@ -69,6 +69,89 @@ export class DataGatheringService {
return this.dataGatheringQueue.addBulk(jobs);
}
public async gather7Days() {
await this.gatherSymbols({
dataGatheringItems: await this.getCurrencies7D(),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: true
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: false
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherMax() {
const dataGatheringItems = await this.getSymbolsMax();
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) {
const dataGatheringItems = (await this.getSymbolsMax())
.filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
})
.map((item) => ({
...item,
date: date ?? item.date
}));
await this.gatherSymbols({
dataGatheringItems,
force: true,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}
public async gatherSymbolForDate({
dataSource,
date,
symbol
}: { date: Date } & AssetProfileIdentifier) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [{ dataSource, symbol }],
from: date,
to: date
});
const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
if (marketPrice) {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
date,
marketPrice,
symbol
},
update: { marketPrice },
where: { dataSource_date_symbol: { dataSource, date, symbol } }
});
}
} catch (error) {
this.logger.error(error);
} finally {
return undefined;
}
}
public async gatherAssetProfiles(
aAssetProfileIdentifiers?: AssetProfileIdentifier[]
) {
@ -93,9 +176,7 @@ export class DataGatheringService {
assetProfileIdentifiers
);
for (const assetProfile of Object.values(assetProfiles)) {
const { symbol } = assetProfile;
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolProfile = symbolProfiles.find(
({ symbol: symbolProfileSymbol }) => {
return symbolProfileSymbol === symbol;
@ -197,13 +278,7 @@ export class DataGatheringService {
}
}
public async gatherHourlyMarketData() {
try {
await this.exchangeRateDataService.loadCurrencies();
} catch (error) {
this.logger.error('Could not gather exchange rates', error);
}
public async gatherHourlySymbols() {
const assetProfileIdentifiers =
await this.getHourlyAssetProfileIdentifiers();
@ -243,91 +318,6 @@ export class DataGatheringService {
}
}
public async gatherMax() {
const dataGatheringItems = await this.getSymbolsMax();
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherRecentMarketData() {
await this.gatherSymbols({
dataGatheringItems: await this.getCurrencies7D(),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: true
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: false
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) {
const dataGatheringItems = (await this.getSymbolsMax())
.filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
})
.map((item) => ({
...item,
date: date ?? item.date
}));
await this.gatherSymbols({
dataGatheringItems,
force: true,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}
public async gatherSymbolForDate({
dataSource,
date,
symbol
}: { date: Date } & AssetProfileIdentifier) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [{ dataSource, symbol }],
from: date,
to: date
});
const marketPrice =
historicalData[getAssetProfileIdentifier({ dataSource, symbol })][
format(date, DATE_FORMAT)
].marketPrice;
if (marketPrice) {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
date,
marketPrice,
symbol
},
update: { marketPrice },
where: { dataSource_date_symbol: { dataSource, date, symbol } }
});
}
} catch (error) {
this.logger.error(error);
} finally {
return undefined;
}
}
public async gatherSymbols({
dataGatheringItems,
force = false,

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

@ -2,7 +2,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY,
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
@ -36,14 +35,7 @@ export class StatisticsGatheringProcessor {
private readonly propertyService: PropertyService
) {}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME
})
@Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME)
public async gatherDockerHubPullsStatistics() {
this.logger.log('Docker Hub pulls statistics gathering has been started');
@ -57,14 +49,7 @@ export class StatisticsGatheringProcessor {
this.logger.log('Docker Hub pulls statistics gathering has been completed');
}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME
})
@Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME)
public async gatherGitHubContributorsStatistics() {
this.logger.log(
'GitHub contributors statistics gathering has been started'
@ -82,14 +67,7 @@ export class StatisticsGatheringProcessor {
);
}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME
})
@Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME)
public async gatherGitHubStargazersStatistics() {
this.logger.log('GitHub stargazers statistics gathering has been started');
@ -105,14 +83,7 @@ export class StatisticsGatheringProcessor {
);
}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME
})
@Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME)
public async gatherUptimeStatistics() {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID

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

@ -3,7 +3,7 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolStocks
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import {
resolveFearAndGreedIndex,
@ -49,7 +49,7 @@ export class TwitterBotService implements OnModuleInit {
const symbolItem = await this.symbolService.get({
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
symbol: ghostfolioFearAndGreedIndexSymbol
}
});

12
apps/client/project.json

@ -26,10 +26,6 @@
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"ja": {
"baseHref": "/ja/",
"translation": "apps/client/src/locales/messages.ja.xlf"
},
"ko": {
"baseHref": "/ko/",
"translation": "apps/client/src/locales/messages.ko.xlf"
@ -118,10 +114,6 @@
"baseHref": "/it/",
"localize": ["it"]
},
"development-ja": {
"baseHref": "/ja/",
"localize": ["ja"]
},
"development-ko": {
"baseHref": "/ko/",
"localize": ["ko"]
@ -247,9 +239,6 @@
"development-it": {
"buildTarget": "client:build:development-it"
},
"development-ja": {
"buildTarget": "client:build:development-ja"
},
"development-ko": {
"buildTarget": "client:build:development-ko"
},
@ -289,7 +278,6 @@
"messages.es.xlf",
"messages.fr.xlf",
"messages.it.xlf",
"messages.ja.xlf",
"messages.ko.xlf",
"messages.nl.xlf",
"messages.pl.xlf",

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

@ -2,14 +2,14 @@
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.alias }}
</td>
</ng-container>
<ng-container matColumnDef="grantee">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.grantee }}
</td>
</ng-container>

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

@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_DATE_RANGE,
DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
} from '@ghostfolio/common/config';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
@ -245,7 +245,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.balance = balance;
if (
this.balance >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES &&
this.balance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES &&
this.data.deviceType === 'mobile'
) {
this.balancePrecision = 0;
@ -257,7 +257,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.dividendInBaseCurrencyPrecision = 0;
}
@ -267,7 +267,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.equity >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
this.equity >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.equityPrecision = 0;
}
@ -280,7 +280,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.interestInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.interestInBaseCurrencyPrecision = 0;
}

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

@ -27,7 +27,7 @@
<ng-container matColumnDef="index">
<th
*matHeaderCellDef
class="justify-content-end px-1 py-2"
class="px-1 py-2 text-right"
mat-header-cell
mat-sort-header="id"
>

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

@ -305,9 +305,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
);
}
protected onGatherMax() {
protected onGather7Days() {
this.adminService
.gatherMax()
.gather7Days()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
@ -316,16 +316,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
});
}
protected onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
protected onGatherRecentMarketData() {
protected onGatherMax() {
this.adminService
.gatherRecentMarketData()
.gatherMax()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
@ -334,6 +327,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
});
}
protected onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
protected onOpenAssetProfileDialog({
dataSource,
symbol

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

@ -220,7 +220,7 @@
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onGatherRecentMarketData()">
<button mat-menu-item (click)="onGather7Days()">
<ng-container i18n
>Gather Recent Historical Market Data</ng-container
>
@ -285,8 +285,7 @@
!canDeleteAssetProfile({
activitiesCount: element.activitiesCount,
isBenchmark: element.isBenchmark,
symbol: element.symbol,
watchedByCount: element.watchedByCount
symbol: element.symbol
})
"
(click)="

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

@ -550,7 +550,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) as Record<string, string>,
locale:
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value || undefined,
?.value ?? undefined,
mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined,

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

@ -165,11 +165,7 @@
</div>
} @else {
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[enableCopyToClipboardButton]="true"
[value]="assetProfile?.symbol"
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
>Symbol</gf-value
>
</div>
@ -206,7 +202,6 @@
<div class="col-6 mb-3">
<gf-value
size="medium"
[enableCopyToClipboardButton]="true"
[hidden]="!assetProfile?.isin"
[value]="assetProfile?.isin"
>ISIN</gf-value

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

@ -96,9 +96,3 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"
[pageSize]="pageSize"
/>

6
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -1,5 +1,4 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
@ -23,7 +22,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -48,7 +46,6 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
IonIcon,
MatButtonModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
RouterModule
@ -62,13 +59,11 @@ export class GfAdminPlatformComponent implements OnInit {
protected dataSource = new MatTableDataSource<Platform>();
protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions'];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected platforms: Platform[];
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort);
private readonly adminService = inject(AdminService);
@ -150,7 +145,6 @@ export class GfAdminPlatformComponent implements OnInit {
this.platforms = platforms;
this.dataSource = new MatTableDataSource(platforms);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase;

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

@ -10,33 +10,7 @@
}
.mat-mdc-card {
--gradient-opacity: 50%;
--mat-card-outlined-outline-color: rgb(193, 219, 254);
background-color: white;
background-image: linear-gradient(
109.6deg,
color-mix(in srgb, rgba(255, 255, 255, 1) 100%, transparent) 11.2%,
color-mix(
in srgb,
rgba(221, 108, 241, 0.26) var(--gradient-opacity),
transparent
)
42%,
color-mix(
in srgb,
rgba(229, 106, 253, 0.71) var(--gradient-opacity),
transparent
)
71.5%,
color-mix(
in srgb,
rgba(123, 183, 253, 1) var(--gradient-opacity),
transparent
)
100%
);
color: rgb(var(--dark-primary-text));
--mat-card-outlined-container-color: whitesmoke;
.mat-mdc-card-actions {
min-height: 0;
@ -58,6 +32,6 @@
:host-context(.theme-dark) {
.mat-mdc-card {
--mat-card-outlined-outline-color: white;
--mat-card-outlined-container-color: #222222;
}
}

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

@ -89,9 +89,3 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"
[pageSize]="pageSize"
/>

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

@ -1,5 +1,4 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
@ -22,7 +21,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -46,7 +44,6 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
IonIcon,
MatButtonModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
RouterModule
@ -65,13 +62,11 @@ export class GfAdminTagComponent implements OnInit {
'activities',
'actions'
];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[];
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
@ -152,7 +147,6 @@ export class GfAdminTagComponent implements OnInit {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase;

7
apps/client/src/app/components/footer/footer.component.html

@ -141,13 +141,6 @@
<li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li>
<!--
<li>
<a href="../ja" title="Ghostfolio in Japanese (日本語)"
>Japanese (日本語)</a
>
</li>
-->
<li>
<a href="../ko" title="Ghostfolio in Korean (한국어)"
>Korean (한국어)</a

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

@ -2,7 +2,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
} from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import {
@ -297,11 +297,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
value
}) => {
this.activitiesCount = activitiesCount;
this.assetProfile = assetProfile;
this.averagePrice = averagePrice;
if (
this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES &&
this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES &&
this.data.deviceType === 'mobile'
) {
this.averagePricePrecision = 0;
@ -320,7 +319,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.dividendInBaseCurrencyPrecision = 0;
}
@ -358,7 +357,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.investmentInBaseCurrencyWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0;
}
@ -368,7 +367,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.marketPriceMaxPrecision = 0;
}
@ -377,14 +376,14 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.marketPriceMinPrecision = 0;
}
if (
this.data.deviceType === 'mobile' &&
this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.marketPricePrecision = 0;
}
@ -406,7 +405,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (
this.data.deviceType === 'mobile' &&
this.netPerformanceWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.netPerformanceWithCurrencyEffectPrecision = 0;
}
@ -425,7 +424,9 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}
}
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${assetProfile?.symbol}%0DData Source: ${assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {};
this.assetProfile = assetProfile;
this.tags = tags.map((tag) => {
return {
@ -438,22 +439,16 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.value = value;
const reportDataGlitchSubject = `Ghostfolio Data Glitch Report${
this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : ''
}`;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.assetProfile?.symbol}%0DData Source: ${this.assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
if (this.assetProfile?.assetClass) {
this.assetClass = translate(this.assetProfile?.assetClass);
if (assetProfile?.assetClass) {
this.assetClass = translate(assetProfile?.assetClass);
}
if (this.assetProfile?.assetSubClass) {
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
if (assetProfile?.assetSubClass) {
this.assetSubClass = translate(assetProfile?.assetSubClass);
}
if (this.assetProfile?.countries?.length > 0) {
for (const country of this.assetProfile.countries) {
if (assetProfile?.countries?.length > 0) {
for (const country of assetProfile.countries) {
this.countries[country.code] = {
name: getCountryName({ code: country.code }),
value: country.weight
@ -461,8 +456,8 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}
}
if (this.assetProfile?.sectors?.length > 0) {
for (const sector of this.assetProfile.sectors) {
if (assetProfile?.sectors?.length > 0) {
for (const sector of assetProfile.sectors) {
this.sectors[sector.name] = {
name: translate(sector.name),
value: sector.weight

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

@ -1,6 +1,6 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import {
Benchmark,
@ -86,7 +86,7 @@ export class GfHomeMarketComponent implements OnInit {
.fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ historicalData, marketPrice }) => {

79
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,17 +1,6 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import { NotificationService } from '@ghostfolio/ui/notifications';
import {
GfPortfolioFilterFormComponent,
getAssetClassFilters,
getFiltersFromPortfolioFilterFormValue,
getHoldingsForFilter,
getPortfolioFilterFormValue,
getTagFilters
} from '@ghostfolio/ui/portfolio-filter-form';
import { DataService } from '@ghostfolio/ui/services';
import type { HttpErrorResponse } from '@angular/common/http';
@ -51,7 +40,6 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
host: { class: 'h-100' },
imports: [
FormsModule,
GfPortfolioFilterFormComponent,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
@ -64,16 +52,9 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-access-dialog.html'
})
export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public accounts: AccountWithPlatform[] = [];
public assetClasses: Filter[] = [];
public holdings: PortfolioPosition[] = [];
public tags: Filter[] = [];
protected accessForm: FormGroup;
protected readonly mode: 'create' | 'update';
private hasExperimentalFeatures = false;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly data =
@ -87,26 +68,17 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder);
private readonly notificationService = inject(NotificationService);
private readonly userService = inject(UserService);
public constructor() {
this.mode = this.data.access ? 'update' : 'create';
}
public get canApplyFilters() {
return (
this.accessForm?.get('type')?.value === 'PUBLIC' &&
this.hasExperimentalFeatures
);
}
public ngOnInit() {
const access = this.data?.access;
const isPublic = access?.type === 'PUBLIC';
this.accessForm = this.formBuilder.group({
alias: [access?.alias ?? ''],
filters: [null],
granteeUserId: [
access?.grantee ?? null,
isPublic ? null : Validators.required
@ -121,19 +93,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
]
});
this.assetClasses = getAssetClassFilters();
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accounts, settings, tags }) => {
this.accounts = accounts;
this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false;
this.tags = getTagFilters(tags);
this.changeDetectorRef.markForCheck();
});
this.accessForm
.get('type')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
@ -143,7 +102,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
if (accessType === 'PRIVATE') {
granteeUserIdControl?.setValidators(Validators.required);
this.accessForm.get('filters')?.setValue(null);
} else {
granteeUserIdControl?.clearValidators();
granteeUserIdControl?.setValue(null);
@ -156,8 +114,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck();
});
this.loadHoldings();
}
protected onCancel() {
@ -172,18 +128,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
}
private buildFilters(): Filter[] {
return getFiltersFromPortfolioFilterFormValue(
this.accessForm.get('filters')?.value
);
}
private async createAccess() {
const filters = this.buildFilters();
const access: CreateAccessDto = {
alias: this.accessForm.get('alias')?.value,
filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value,
permissions: [this.accessForm.get('permissions')?.value]
};
@ -217,19 +164,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
}
private loadHoldings() {
this.dataService
.fetchPortfolioHoldings()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = getHoldingsForFilter(holdings);
this.updateFiltersFormControl(this.data.access?.settings?.filters);
this.changeDetectorRef.markForCheck();
});
}
private async updateAccess() {
const accessId = this.data.access?.id;
@ -237,11 +171,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return;
}
const filters = this.buildFilters();
const access: UpdateAccessDto = {
alias: this.accessForm.get('alias')?.value,
filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: accessId,
permissions: [this.accessForm.get('permissions')?.value]
@ -275,14 +206,4 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
console.error(error);
}
}
private updateFiltersFormControl(filters: Filter[] | undefined) {
if (!filters?.length) {
return;
}
this.accessForm
.get('filters')
?.setValue(getPortfolioFilterFormValue(filters, this.holdings));
}
}

12
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -59,18 +59,6 @@
</mat-form-field>
</div>
}
@if (canApplyFilters) {
<h2 class="h6" i18n>Portfolio Filters</h2>
<gf-portfolio-filter-form
appearance="outline"
class="w-100"
formControlName="filters"
[accounts]="accounts"
[assetClasses]="assetClasses"
[holdings]="holdings"
[tags]="tags"
/>
}
</div>
<div class="justify-content-end" mat-dialog-actions>
<button mat-button type="button" (click)="onCancel()">

1
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -229,7 +229,6 @@ export class GfUserAccountAccessComponent implements OnInit {
grantee: access.grantee === 'Public' ? undefined : access.grantee,
id: access.id,
permissions: access.permissions,
settings: access.settings,
type: access.type
}
} satisfies CreateOrUpdateAccessDialogParams,

3
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -12,7 +12,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
@ -52,7 +51,6 @@ import { catchError } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
GfValueComponent,
IonIcon,
MatButtonModule,
MatCardModule,
@ -91,7 +89,6 @@ export class GfUserAccountSettingsComponent implements OnInit {
'es',
'fr',
'it',
// 'ja',
'ko',
'nl',
'pl',

17
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -103,15 +103,6 @@
>Italiano (<ng-container i18n>Community</ng-container
>)</mat-option
>
<!--
@if (user?.settings?.isExperimentalFeatures) {
<mat-option value="ja"
>Japanese / 日本語 (<ng-container i18n
>Community</ng-container
>)</mat-option
>
}
-->
@if (user?.settings?.isExperimentalFeatures) {
<mat-option value="ko"
>Korean / 한국어 (<ng-container i18n
@ -269,13 +260,7 @@
<div class="pr-1 w-50">
Ghostfolio <ng-container i18n>User ID</ng-container>
</div>
<div class="pl-1 w-50">
<gf-value
class="text-monospace"
[enableCopyToClipboardButton]="true"
[value]="user?.id"
/>
</div>
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
</div>
<div class="align-items-center d-flex py-1">
<div class="pr-1 w-50"></div>

3
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -171,8 +171,7 @@
mat-cell
>
@if (element.price === null) {
<span></span>
<span class="ml-1">{{ baseCurrency }}</span>
<span></span>
} @else {
<gf-value
class="d-inline-block justify-content-end"

7
apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts

@ -1,12 +1,5 @@
import { FormControl, FormGroup } from '@angular/forms';
import { Account } from '@prisma/client';
export interface TransferBalanceDialogParams {
accounts: Account[];
}
export type TransferBalanceForm = FormGroup<{
balance: FormControl<number | string | null>;
fromAccount: FormControl<string | null>;
toAccount: FormControl<string | null>;
}>;

78
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -1,9 +1,10 @@
import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import {
FormControl,
AbstractControl,
FormBuilder,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
@ -20,10 +21,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { Account } from '@prisma/client';
import {
TransferBalanceDialogParams,
TransferBalanceForm
} from './interfaces/interfaces';
import { TransferBalanceDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -42,63 +40,57 @@ import {
templateUrl: 'transfer-balance-dialog.html'
})
export class GfTransferBalanceDialogComponent {
protected readonly accounts: Account[] =
inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA).accounts;
protected currency: string;
protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup(
{
balance: new FormControl<number | string | null>('', Validators.required),
fromAccount: new FormControl<string | null>('', Validators.required),
toAccount: new FormControl<string | null>('', Validators.required)
},
{
validators: this.compareAccounts
}
);
public accounts: Account[] = [];
public currency: string;
public transferBalanceForm: FormGroup;
private readonly dialogRef =
inject<MatDialogRef<GfTransferBalanceDialogComponent>>(MatDialogRef);
public constructor(
@Inject(MAT_DIALOG_DATA) public data: TransferBalanceDialogParams,
public dialogRef: MatDialogRef<GfTransferBalanceDialogComponent>,
private formBuilder: FormBuilder
) {}
public ngOnInit() {
this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe(
(id) => {
const currency = this.accounts.find((account) => {
return account.id === id;
})?.currency;
this.accounts = this.data.accounts;
if (currency) {
this.currency = currency;
}
this.transferBalanceForm = this.formBuilder.group(
{
balance: ['', Validators.required],
fromAccount: ['', Validators.required],
toAccount: ['', Validators.required]
},
{
validators: this.compareAccounts
}
);
this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => {
this.currency = this.accounts.find((account) => {
return account.id === id;
}).currency;
});
}
protected onCancel() {
public onCancel() {
this.dialogRef.close();
}
protected onSubmit() {
public onSubmit() {
const account: TransferBalanceDto = {
accountIdFrom: this.transferBalanceForm.controls.fromAccount.value ?? '',
accountIdTo: this.transferBalanceForm.controls.toAccount.value ?? '',
balance: Number(this.transferBalanceForm.controls.balance.value)
accountIdFrom: this.transferBalanceForm.get('fromAccount').value,
accountIdTo: this.transferBalanceForm.get('toAccount').value,
balance: this.transferBalanceForm.get('balance').value
};
this.dialogRef.close({ account });
}
private compareAccounts(
formGroup: TransferBalanceForm
): ValidationErrors | null {
const accountFrom = formGroup.controls.fromAccount;
const accountTo = formGroup.controls.toAccount;
private compareAccounts(control: AbstractControl): ValidationErrors {
const accountFrom = control.get('fromAccount');
const accountTo = control.get('toAccount');
if (accountFrom.value === accountTo.value) {
return { invalid: true };
}
return null;
}
}

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

@ -94,7 +94,7 @@ export class GfApiPageComponent implements OnInit {
private fetchAssetProfile({ symbol }: { symbol: string }) {
return this.http
.get<DataProviderGhostfolioAssetProfileResponse>(
`/api/v1/data-providers/ghostfolio/asset-profile/${encodeURIComponent(symbol)}`,
`/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ headers: this.getHeaders() }
)
.pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef));
@ -107,7 +107,7 @@ export class GfApiPageComponent implements OnInit {
return this.http
.get<DividendsResponse>(
`/api/v2/data-providers/ghostfolio/dividends/${encodeURIComponent(symbol)}`,
`/api/v2/data-providers/ghostfolio/dividends/${symbol}`,
{
params,
headers: this.getHeaders()
@ -129,7 +129,7 @@ export class GfApiPageComponent implements OnInit {
return this.http
.get<HistoricalResponse>(
`/api/v2/data-providers/ghostfolio/historical/${encodeURIComponent(symbol)}`,
`/api/v2/data-providers/ghostfolio/historical/${symbol}`,
{
params,
headers: this.getHeaders()

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

@ -164,14 +164,12 @@
@for (message of errorMessages; track message; let i = $index) {
<mat-expansion-panel [disabled]="!details[i]">
<mat-expansion-panel-header class="pl-1">
<mat-panel-title class="w-100">
<div class="d-flex w-100">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline" />
</div>
<div class="text-truncate" title="{{ message }}">
{{ message }}
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>

11
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -1,5 +1,3 @@
@use '@angular/material' as mat;
:host {
display: block;
@ -43,13 +41,8 @@
}
.mat-expansion-panel {
@include mat.expansion-overrides(
(
container-background-color: transparent,
container-elevation-shadow: none,
container-shape: 0
)
);
background: none;
box-shadow: none;
.mat-expansion-panel-header {
color: inherit;

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

@ -12,7 +12,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MarketAdvanced } from '@ghostfolio/common/types';
import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -24,9 +24,7 @@ import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
import {
ChangeDetectorRef,
Component,
computed,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -43,9 +41,6 @@ import {
} from '@prisma/client';
import { isNumber } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { filter, switchMap, tap } from 'rxjs';
import { AllocationsPageParams } from './interfaces/interfaces';
@Component({
imports: [
@ -62,23 +57,21 @@ import { AllocationsPageParams } from './interfaces/interfaces';
templateUrl: './allocations-page.html'
})
export class GfAllocationsPageComponent implements OnInit {
protected accounts: {
public accounts: {
[id: string]: Pick<Account, 'name'> & {
id: string;
value: number;
};
};
protected continents: {
public continents: {
[code: string]: { name: string; value: number };
};
protected countries: {
public countries: {
[code: string]: { name: string; value: number };
};
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected hasImpersonationId: boolean;
protected holdings: {
public deviceType: string;
public hasImpersonationId: boolean;
public holdings: {
[symbol: string]: Pick<
PortfolioPosition['assetProfile'],
| 'assetClass'
@ -89,26 +82,28 @@ export class GfAllocationsPageComponent implements OnInit {
| 'name'
> & { etfProvider: string; exchange?: string; value: number };
};
protected isLoading = false;
protected markets: PortfolioDetails['markets'];
protected marketsAdvanced: {
public isLoading = false;
public markets: {
[key in Market]: { id: Market; valueInPercentage: number };
};
public marketsAdvanced: {
[key in MarketAdvanced]: {
id: MarketAdvanced;
name: string;
value: number;
};
};
protected platforms: {
public platforms: {
[id: string]: Pick<Platform, 'name'> & {
id: string;
value: number;
};
};
protected portfolioDetails: PortfolioDetails;
protected sectors: {
public portfolioDetails: PortfolioDetails;
public sectors: {
[name: string]: { name: string; value: number };
};
protected symbols: {
public symbols: {
[name: string]: {
dataSource?: DataSource;
name: string;
@ -116,46 +111,38 @@ export class GfAllocationsPageComponent implements OnInit {
value: number;
};
};
protected topHoldings: HoldingWithParents[];
protected readonly UNKNOWN_KEY = UNKNOWN_KEY;
protected user: User;
private topHoldingsMap: {
public topHoldings: HoldingWithParents[];
public topHoldingsMap: {
[name: string]: { name: string; value: number };
};
private totalValueInEtf = 0;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
public totalValueInEtf = 0;
public UNKNOWN_KEY = UNKNOWN_KEY;
public user: User;
public worldMapChartFormat: string;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceDetectorService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
({ accountId, accountDetailDialog }: AllocationsPageParams) => {
if (accountId && accountDetailDialog) {
this.openAccountDetailDialog(accountId);
}
.subscribe((params) => {
if (params['accountId'] && params['accountDetailDialog']) {
this.openAccountDetailDialog(params['accountId']);
}
);
}
protected get worldMapChartFormat(): string {
return this.showValuesInPercentage()
? '{0}%'
: `{0} ${this.user?.settings?.baseCurrency}`;
});
}
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -164,58 +151,56 @@ export class GfAllocationsPageComponent implements OnInit {
});
this.userService.stateChanged
.pipe(
filter((state) => !!state?.user),
tap((state) => {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.worldMapChartFormat = this.showValuesInPercentage()
? `{0}%`
: `{0} ${this.user?.settings?.baseCurrency}`;
this.isLoading = true;
this.initialize();
this.changeDetectorRef.markForCheck();
}),
switchMap(() => this.fetchPortfolioDetails()),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((portfolioDetails) => {
this.initialize();
this.fetchPortfolioDetails()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((portfolioDetails) => {
this.initialize();
this.portfolioDetails = portfolioDetails;
this.portfolioDetails = portfolioDetails;
this.initializeAllocationsData();
this.initializeAllocationsData();
this.isLoading = false;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
this.initialize();
}
protected onAccountChartClicked({ accountId }: { accountId: string }) {
public onAccountChartClicked({ accountId }: { accountId: string }) {
if (accountId && accountId !== UNKNOWN_KEY) {
void this.router.navigate([], {
this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
});
}
}
protected onSymbolChartClicked({
dataSource,
symbol
}: AssetProfileIdentifier) {
public onSymbolChartClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) {
void this.router.navigate([], {
this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
});
}
}
protected showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
}
private extractCurrency({
assetClass,
assetSubClass,
@ -241,9 +226,9 @@ export class GfAllocationsPageComponent implements OnInit {
name
}: {
assetSubClass: PortfolioPosition['assetProfile']['assetSubClass'];
name?: string;
name: string;
}) {
if (assetSubClass === 'ETF' && name) {
if (assetSubClass === 'ETF') {
const [firstWord] = name.split(' ');
return firstWord;
}
@ -313,7 +298,7 @@ export class GfAllocationsPageComponent implements OnInit {
this.platforms = {};
this.portfolioDetails = {
accounts: {},
createdAt: new Date(),
createdAt: undefined,
holdings: {},
platforms: {},
summary: undefined
@ -342,7 +327,7 @@ export class GfAllocationsPageComponent implements OnInit {
let value = 0;
if (this.showValuesInPercentage()) {
value = valueInPercentage ?? 0;
value = valueInPercentage;
} else {
value = valueInBaseCurrency;
}
@ -357,24 +342,30 @@ export class GfAllocationsPageComponent implements OnInit {
for (const [symbol, position] of Object.entries(
this.portfolioDetails.holdings
)) {
let value = 0;
if (this.showValuesInPercentage()) {
value = position.allocationInPercentage;
} else {
value = position.valueInBaseCurrency;
}
this.holdings[symbol] = {
value,
assetClass:
position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetProfile.assetClassLabel ?? UNKNOWN_KEY,
assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY,
assetSubClass:
position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel:
position.assetProfile.assetSubClassLabel ?? UNKNOWN_KEY,
position.assetProfile.assetSubClassLabel || UNKNOWN_KEY,
currency: this.extractCurrency(position.assetProfile),
etfProvider: this.extractEtfProvider({
assetSubClass: position.assetProfile.assetSubClass,
name: position.assetProfile.name
}),
exchange: position.exchange,
name: position.assetProfile.name,
value: this.showValuesInPercentage()
? position.allocationInPercentage
: (position.valueInBaseCurrency ?? 0)
name: position.assetProfile.name
};
// Prepare analysis data by continents, countries, holdings and sectors
@ -382,50 +373,53 @@ export class GfAllocationsPageComponent implements OnInit {
if (position.assetProfile.countries.length > 0) {
for (const country of position.assetProfile.countries) {
const { code, continent, weight } = country;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const continentData = this.continents[continent];
if (continentData) {
continentData.value += weight * value;
if (this.continents[continent]?.value) {
this.continents[continent].value +=
weight *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else {
this.continents[continent] = {
name: translate(continent),
value: weight * value
value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
};
}
const countryData = this.countries[code];
if (countryData) {
countryData.value += weight * value;
if (this.countries[code]?.value) {
this.countries[code].value +=
weight *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else {
this.countries[code] = {
name: getCountryName({ code }),
value: weight * value
value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
};
}
}
} else {
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const continentData = this.continents[UNKNOWN_KEY];
if (continentData) {
continentData.value += value;
}
const countryData = this.countries[UNKNOWN_KEY];
if (countryData) {
countryData.value += value;
}
this.continents[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
this.countries[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
}
if (position.assetProfile.holdings.length > 0) {
@ -435,18 +429,21 @@ export class GfAllocationsPageComponent implements OnInit {
valueInBaseCurrency
} of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name);
const value = isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage * (position.valueInPercentage ?? 0);
const holdingData = this.topHoldingsMap[normalizedAssetName];
if (holdingData) {
holdingData.value += value;
if (this.topHoldingsMap[normalizedAssetName]?.value) {
this.topHoldingsMap[normalizedAssetName].value += isNumber(
valueInBaseCurrency
)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage;
} else {
this.topHoldingsMap[normalizedAssetName] = {
name,
value
value: isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage
};
}
}
@ -455,33 +452,30 @@ export class GfAllocationsPageComponent implements OnInit {
if (position.assetProfile.sectors.length > 0) {
for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const sectorData = this.sectors[name];
if (sectorData) {
sectorData.value += weight * value;
if (this.sectors[name]?.value) {
this.sectors[name].value +=
weight *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else {
this.sectors[name] = {
name: translate(name),
value: weight * value
value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
};
}
}
} else {
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const sectorData = this.sectors[UNKNOWN_KEY];
if (sectorData) {
sectorData.value += value;
}
this.sectors[UNKNOWN_KEY].value += isNumber(
position.valueInBaseCurrency
)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
}
if (this.holdings[symbol].assetSubClass === 'ETF') {
@ -490,26 +484,23 @@ export class GfAllocationsPageComponent implements OnInit {
this.symbols[prettifySymbol(symbol)] = {
dataSource: position.assetProfile.dataSource,
name: position.assetProfile.name ?? '',
name: position.assetProfile.name,
symbol: prettifySymbol(symbol),
value:
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0
value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage
};
}
this.markets = this.portfolioDetails.markets;
if (this.portfolioDetails.marketsAdvanced) {
Object.values(this.portfolioDetails.marketsAdvanced).forEach(
({ id, valueInBaseCurrency, valueInPercentage }) => {
this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: valueInPercentage;
}
);
}
Object.values(this.portfolioDetails.marketsAdvanced).forEach(
({ id, valueInBaseCurrency, valueInPercentage }) => {
this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: valueInPercentage;
}
);
for (const [
id,
@ -518,7 +509,7 @@ export class GfAllocationsPageComponent implements OnInit {
let value = 0;
if (this.showValuesInPercentage()) {
value = valueInPercentage ?? 0;
value = valueInPercentage;
} else {
value = valueInBaseCurrency;
}
@ -531,11 +522,12 @@ export class GfAllocationsPageComponent implements OnInit {
}
this.topHoldings = Object.values(this.topHoldingsMap)
.map(({ name, value }): HoldingWithParents => {
.map(({ name, value }) => {
if (this.showValuesInPercentage()) {
return {
name,
allocationInPercentage: value
allocationInPercentage: value,
valueInBaseCurrency: null
};
}
@ -555,12 +547,11 @@ export class GfAllocationsPageComponent implements OnInit {
}
);
return currentParentHolding &&
isNumber(currentParentHolding.valueInBaseCurrency)
return currentParentHolding
? {
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.assetProfile.name ?? '',
name: holding.assetProfile.name,
position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency:
@ -605,22 +596,26 @@ export class GfAllocationsPageComponent implements OnInit {
autoFocus: false,
data: {
accountId: aAccountId,
deviceType: this.deviceType(),
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
void this.router.navigate(['.'], { relativeTo: this.route });
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
}
}

2
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -115,7 +115,7 @@
[isInPercentage]="showValuesInPercentage()"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
[showLabels]="deviceType() !== 'mobile'"
[showLabels]="deviceType !== 'mobile'"
(proportionChartClicked)="onSymbolChartClicked($event)"
/>
</mat-card-content>

6
apps/client/src/app/pages/portfolio/allocations/interfaces/interfaces.ts

@ -1,6 +0,0 @@
import { Params } from '@angular/router';
export interface AllocationsPageParams extends Params {
accountDetailDialog?: string;
accountId?: string;
}

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

@ -5,95 +5,84 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
computed,
inject
} from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-product-page',
styleUrls: ['./product-page.scss'],
templateUrl: './product-page.html'
})
export class GfProductPageComponent {
protected readonly price = computed(() => {
export class GfProductPageComponent implements OnInit {
public key: string;
public price: number;
public product1: Product;
public product2: Product;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
public tags: string[];
public constructor(
private dataService: DataService,
private route: ActivatedRoute
) {}
public ngOnInit() {
const { subscriptionOffer } = this.dataService.fetchInfo();
return subscriptionOffer?.price;
});
protected readonly product1 = computed<Product>(() => ({
founded: 2021,
hasFreePlan: true,
hasSelfHostingAbility: true,
isOpenSource: true,
key: 'ghostfolio',
languages: [
'Chinese (简体中文)',
'Deutsch',
'English',
'Español',
'Français',
'Italiano',
// 'Japanese (日本語)',
'Korean (한국어)',
'Nederlands',
'Português',
'Türkçe'
],
name: 'Ghostfolio',
origin: getCountryName({ code: 'CH' }),
regions: [$localize`Global`],
slogan: 'Open Source Wealth Management',
useAnonymously: true
}));
this.price = subscriptionOffer?.price;
protected readonly product2 = computed<Product>(() => {
const product = personalFinanceTools.find(({ key }) => {
return key === this.route.snapshot.data['key'];
});
this.product1 = {
founded: 2021,
hasFreePlan: true,
hasSelfHostingAbility: true,
isOpenSource: true,
key: 'ghostfolio',
languages: [
'Chinese (简体中文)',
'Deutsch',
'English',
'Español',
'Français',
'Italiano',
'Korean (한국어)',
'Nederlands',
'Português',
'Türkçe'
],
name: 'Ghostfolio',
origin: getCountryName({ code: 'CH' }),
regions: [$localize`Global`],
slogan: 'Open Source Wealth Management',
useAnonymously: true
};
const mappedProduct = {
key: product?.key ?? '',
name: product?.name ?? '',
...product
this.product2 = {
...personalFinanceTools.find(({ key }) => {
return key === this.route.snapshot.data['key'];
})
};
if (mappedProduct.origin) {
mappedProduct.origin = getCountryName({ code: mappedProduct.origin });
if (this.product2.origin) {
this.product2.origin = getCountryName({ code: this.product2.origin });
}
if (mappedProduct.regions) {
mappedProduct.regions = mappedProduct.regions.map((region) => {
return region === 'Global'
? translate(region)
: getCountryName({ code: region });
if (this.product2.regions) {
this.product2.regions = this.product2.regions.map((region) => {
return translate(region);
});
}
return mappedProduct;
});
protected readonly routerLinkAbout = publicRoutes.about.routerLink;
protected readonly routerLinkFeatures = publicRoutes.features.routerLink;
protected readonly routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
protected readonly tags = computed<string[]>(() => {
const product1 = this.product1();
const product2 = this.product2();
return [
product1.name,
product1.origin,
product2.name,
product2.origin,
this.tags = [
this.product1.name,
this.product1.origin,
this.product2.name,
this.product2.origin,
$localize`Alternative`,
$localize`App`,
$localize`Budgeting`,
@ -114,14 +103,11 @@ export class GfProductPageComponent {
$localize`Wealth Management`,
`WealthTech`
]
.filter((item): item is string => {
.filter((item) => {
return !!item;
})
.sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
});
private readonly dataService = inject(DataService);
private readonly route = inject(ActivatedRoute);
}
}

116
apps/client/src/app/pages/resources/personal-finance-tools/product-page.html

@ -6,10 +6,10 @@
<h1 class="mb-1">
<strong>Ghostfolio</strong>:
<ng-container i18n>The Open Source Alternative to</ng-container
>&nbsp;<strong>{{ product2().name }}</strong>
>&nbsp;<strong>{{ product2.name }}</strong>
</h1>
</div>
@if (product2().isArchived) {
@if (product2.isArchived) {
<div class="info-container my-4 p-2 rounded text-center text-muted">
<ng-container i18n>This page has been archived.</ng-container>
</div>
@ -17,7 +17,7 @@
<section class="mb-4">
<p i18n>
Are you looking for an open source alternative to
{{ product2().name }}?
{{ product2.name }}?
<a [routerLink]="routerLinkAbout">Ghostfolio</a> is a powerful
portfolio management tool that provides individuals with a
comprehensive platform to track, analyze, and optimize their
@ -31,7 +31,7 @@
</p>
<p i18n>
Ghostfolio is an open source software (OSS), providing a
cost-effective alternative to {{ product2().name }} making it
cost-effective alternative to {{ product2.name }} making it
particularly suitable for individuals on a tight budget, such as
those
<a href="../en/blog/2023/07/exploring-the-path-to-fire"
@ -42,9 +42,9 @@
</p>
<p i18n>
Let’s dive deeper into the detailed Ghostfolio vs
{{ product2().name }} comparison table below to gain a thorough
{{ product2.name }} comparison table below to gain a thorough
understanding of how Ghostfolio positions itself relative to
{{ product2().name }}. We will explore various aspects such as
{{ product2.name }}. We will explore various aspects such as
features, data privacy, pricing, and more, allowing you to make a
well-informed choice for your personal requirements.
</p>
@ -54,7 +54,7 @@
<caption class="text-center" i18n>
Ghostfolio vs
{{
product2().name
product2.name
}}
comparison table
</caption>
@ -63,31 +63,31 @@
<th class="mat-mdc-header-cell px-1 py-2"></th>
<th class="mat-mdc-header-cell px-1 py-2">Ghostfolio</th>
<th class="mat-mdc-header-cell px-1 py-2">
{{ product2().name }}
{{ product2.name }}
</th>
</tr>
</thead>
<tbody>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right"></td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().slogan }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().slogan }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1.slogan }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2.slogan }}</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Founded</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().founded }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().founded }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1.founded }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2.founded }}</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Origin</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().origin }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().origin }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1.origin }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2.origin }}</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
region of product1().regions;
region of product1.regions;
track region;
let isLast = $last
) {
@ -96,7 +96,7 @@
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
region of product2().regions;
region of product2.regions;
track region;
let isLast = $last
) {
@ -110,7 +110,7 @@
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
language of product1().languages;
language of product1.languages;
track language;
let isLast = $last
) {
@ -119,7 +119,7 @@
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
language of product2().languages;
language of product2.languages;
track language;
let isLast = $last
) {
@ -132,35 +132,35 @@
Open Source Software
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product1().isOpenSource) {
@if (product1.isOpenSource) {
<span
i18n
i18n-title
title="{{ product1().name }} is Open Source Software"
title="{{ product1.name }} is Open Source Software"
>✅ Yes</span
>
} @else {
<span
i18n
i18n-title
title="{{ product1().name }} is not Open Source Software"
title="{{ product1.name }} is not Open Source Software"
>❌ No</span
>
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product2().isOpenSource) {
@if (product2.isOpenSource) {
<span
i18n
i18n-title
title="{{ product2().name }} is Open Source Software"
title="{{ product2.name }} is Open Source Software"
>✅ Yes</span
>
} @else {
<span
i18n
i18n-title
title="{{ product2().name }} is not Open Source Software"
title="{{ product2.name }} is not Open Source Software"
>❌ No</span
>
}
@ -171,35 +171,35 @@
Self-Hosting
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product1().hasSelfHostingAbility === true) {
@if (product1.hasSelfHostingAbility === true) {
<span
i18n
i18n-title
title="{{ product1().name }} can be self-hosted"
title="{{ product1.name }} can be self-hosted"
>✅ Yes</span
>
} @else if (product1().hasSelfHostingAbility === false) {
} @else if (product1.hasSelfHostingAbility === false) {
<span
i18n
i18n-title
title="{{ product1().name }} cannot be self-hosted"
title="{{ product1.name }} cannot be self-hosted"
>❌ No</span
>
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product2().hasSelfHostingAbility === true) {
@if (product2.hasSelfHostingAbility === true) {
<span
i18n
i18n-title
title="{{ product2().name }} can be self-hosted"
title="{{ product2.name }} can be self-hosted"
>✅ Yes</span
>
} @else if (product2().hasSelfHostingAbility === false) {
} @else if (product2.hasSelfHostingAbility === false) {
<span
i18n
i18n-title
title="{{ product2().name }} cannot be self-hosted"
title="{{ product2.name }} cannot be self-hosted"
>❌ No</span
>
}
@ -210,35 +210,35 @@
Use anonymously
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product1().useAnonymously === true) {
@if (product1.useAnonymously === true) {
<span
i18n
i18n-title
title="{{ product1().name }} can be used anonymously"
title="{{ product1.name }} can be used anonymously"
>✅ Yes</span
>
} @else if (product1().useAnonymously === false) {
} @else if (product1.useAnonymously === false) {
<span
i18n
i18n-title
title="{{ product1().name }} cannot be used anonymously"
title="{{ product1.name }} cannot be used anonymously"
>❌ No</span
>
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product2().useAnonymously === true) {
@if (product2.useAnonymously === true) {
<span
i18n
i18n-title
title="{{ product2().name }} can be used anonymously"
title="{{ product2.name }} can be used anonymously"
>✅ Yes</span
>
} @else if (product2().useAnonymously === false) {
} @else if (product2.useAnonymously === false) {
<span
i18n
i18n-title
title="{{ product2().name }} cannot be used anonymously"
title="{{ product2.name }} cannot be used anonymously"
>❌ No</span
>
}
@ -249,35 +249,35 @@
Free Plan
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product1().hasFreePlan === true) {
@if (product1.hasFreePlan === true) {
<span
i18n
i18n-title
title="{{ product1().name }} offers a free plan"
title="{{ product1.name }} offers a free plan"
>✅ Yes</span
>
} @else if (product1().hasFreePlan === false) {
} @else if (product1.hasFreePlan === false) {
<span
i18n
i18n-title
title="{{ product1().name }} does not offer a free plan"
title="{{ product1.name }} does not offer a free plan"
>❌ No</span
>
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product2().hasFreePlan === true) {
@if (product2.hasFreePlan === true) {
<span
i18n
i18n-title
title="{{ product2().name }} offers a free plan"
title="{{ product2.name }} offers a free plan"
>✅ Yes</span
>
} @else if (product2().hasFreePlan === false) {
} @else if (product2.hasFreePlan === false) {
<span
i18n
i18n-title
title="{{ product2().name }} does not offer a free plan"
title="{{ product2.name }} does not offer a free plan"
>❌ No</span
>
}
@ -286,22 +286,22 @@
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Pricing</td>
<td class="mat-mdc-cell px-1 py-2">
<span i18n>Starting from</span> ${{ price() }} /
<span i18n>Starting from</span> ${{ price }} /
<span i18n>year</span>
</td>
<td class="mat-mdc-cell px-1 py-2">
@if (product2().pricingPerYear) {
@if (product2.pricingPerYear) {
<span i18n>Starting from</span>
{{ product2().pricingPerYear }} /
{{ product2.pricingPerYear }} /
<span i18n>year</span>
}
</td>
</tr>
@if (product1().note || product2().note) {
@if (product1.note || product2.note) {
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Notes</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().note }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().note }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1.note }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2.note }}</td>
</tr>
}
</tbody>
@ -310,9 +310,9 @@
<section class="mb-4">
<p i18n>
Please note that the information provided in the Ghostfolio vs
{{ product2().name }} comparison table is based on our independent
{{ product2.name }} comparison table is based on our independent
research and analysis. This website is not affiliated with
{{ product2().name }} or any other product mentioned in the
{{ product2.name }} or any other product mentioned in the
comparison. As the landscape of personal finance tools evolves, it
is essential to verify any specific details or changes directly from
the respective product page. Data needs a refresh? Help us maintain
@ -337,7 +337,7 @@
</section>
<section class="mb-4">
<ul class="list-inline">
@for (tag of tags(); track tag) {
@for (tag of tags; track tag) {
<li class="list-inline-item">
<span class="badge badge-light">{{ tag }}</span>
</li>
@ -355,7 +355,7 @@
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Ghostfolio vs {{ product2().name }}
Ghostfolio vs {{ product2.name }}
</li>
</ol>
</nav>

296
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

310
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

8789
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

265
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

13
libs/common/src/lib/config.ts

@ -5,13 +5,11 @@ import ms from 'ms';
import { ColorScheme, DateRange } from './types';
export const ghostfolioPrefix = 'GF';
/* @deprecated */
export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`;
export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies =
DataSource.MANUAL;
export const ghostfolioFearAndGreedIndexDataSourceStocks = DataSource.RAPID_API;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`;
export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`;
@ -90,12 +88,8 @@ export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT =
ms('1 minute');
export const DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT =
ms('30 seconds');
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = 30000;
export const DEFAULT_REDACTED_PATHS = [
'accounts[*].balance',
@ -234,7 +228,6 @@ export const HEADER_KEY_SKIP_INTERCEPTOR = 'X-Skip-Interceptor';
export const MAX_TOP_HOLDINGS = 50;
export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100;
export const NUMERICAL_PRECISION_THRESHOLD_4_FIGURES = 1000;
export const NUMERICAL_PRECISION_THRESHOLD_5_FIGURES = 10000;
export const NUMERICAL_PRECISION_THRESHOLD_6_FIGURES = 100000;
@ -259,7 +252,6 @@ export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED';
export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL';
export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH';
export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES';
export const PROPERTY_REFERRAL_PARTNERS = 'REFERRAL_PARTNERS';
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
@ -315,7 +307,6 @@ export const SUPPORTED_LANGUAGE_CODES = [
'es',
'fr',
'it',
// 'ja',
'ko',
'nl',
'pl',

8
libs/common/src/lib/dtos/create-access.dto.ts

@ -1,17 +1,11 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client';
import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsArray()
@IsOptional()
filters?: Filter[];
@IsOptional()
@IsUUID()
granteeUserId?: string;

4
libs/common/src/lib/dtos/create-asset-profile.dto.ts

@ -11,11 +11,11 @@ import {
} from 'class-validator';
export class CreateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsEnum(AssetClass)
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsEnum(AssetSubClass)
@IsOptional()
assetSubClass?: AssetSubClass;

6
libs/common/src/lib/dtos/create-order.dto.ts

@ -21,11 +21,11 @@ export class CreateOrderDto {
@IsString()
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsEnum(AssetClass)
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsEnum(AssetSubClass)
@IsOptional()
assetSubClass?: AssetSubClass;
@ -66,7 +66,7 @@ export class CreateOrderDto {
@IsOptional()
tags?: string[];
@IsEnum(Type, { each: true })
@IsEnum(Type)
type: Type;
@IsNumber()

8
libs/common/src/lib/dtos/update-access.dto.ts

@ -1,17 +1,11 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client';
import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsArray()
@IsOptional()
filters?: Filter[];
@IsOptional()
@IsUUID()
granteeUserId?: string;

4
libs/common/src/lib/dtos/update-asset-profile.dto.ts

@ -18,11 +18,11 @@ import {
} from 'class-validator';
export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsEnum(AssetClass)
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsEnum(AssetSubClass)
@IsOptional()
assetSubClass?: AssetSubClass;

4
libs/common/src/lib/dtos/update-order.dto.ts

@ -20,11 +20,11 @@ export class UpdateOrderDto {
@IsString()
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsEnum(AssetClass)
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsEnum(AssetSubClass)
@IsOptional()
assetSubClass?: AssetSubClass;

22
libs/common/src/lib/helper.ts

@ -1,6 +1,7 @@
import { NumberParser } from '@internationalized/number';
import {
Type as ActivityType,
DataSource,
MarketData,
Prisma,
SymbolProfile,
@ -23,7 +24,6 @@ import {
es,
fr,
it,
ja,
ko,
nl,
pl,
@ -38,6 +38,7 @@ import {
DEFAULT_CURRENCY,
DEFAULT_LOCALE,
DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix
@ -156,6 +157,7 @@ export function canDeleteAssetProfile({
!isBenchmark &&
!isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!isRootCurrency(getCurrencyFromSymbol(symbol)) &&
symbol !== ghostfolioFearAndGreedIndexSymbol &&
symbol !== ghostfolioFearAndGreedIndexSymbolCryptocurrencies &&
symbol !== ghostfolioFearAndGreedIndexSymbolStocks &&
watchedByCount === 0
@ -166,6 +168,14 @@ export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
}
export function decodeDataSource(encodedDataSource: string) {
if (encodedDataSource) {
return Buffer.from(encodedDataSource, 'hex').toString();
}
return undefined;
}
export function downloadAsFile({
content,
contentType = 'text/plain',
@ -191,6 +201,14 @@ export function downloadAsFile({
a.click();
}
export function encodeDataSource(aDataSource: DataSource) {
if (aDataSource) {
return Buffer.from(aDataSource, 'utf-8').toString('hex');
}
return undefined;
}
export function extractNumberFromString({
locale = 'en-US',
value
@ -263,8 +281,6 @@ export function getDateFnsLocale(aLanguageCode?: string) {
return fr;
} else if (aLanguageCode === 'it') {
return it;
} else if (aLanguageCode === 'ja') {
return ja;
} else if (aLanguageCode === 'ko') {
return ko;
} else if (aLanguageCode === 'nl') {

5
libs/common/src/lib/interfaces/access-settings.interface.ts

@ -1,5 +0,0 @@
import { Filter } from './filter.interface';
export interface AccessSettings {
filters?: Filter[];
}

3
libs/common/src/lib/interfaces/access.interface.ts

@ -2,13 +2,10 @@ import { AccessType } from '@ghostfolio/common/types';
import { AccessPermission } from '@prisma/client';
import { AccessSettings } from './access-settings.interface';
export interface Access {
alias?: string;
grantee?: string;
id: string;
permissions: AccessPermission[];
settings?: AccessSettings;
type: AccessType;
}

2
libs/common/src/lib/interfaces/holding.interface.ts

@ -1,5 +1,5 @@
export interface Holding {
allocationInPercentage: number;
name: string;
valueInBaseCurrency?: number;
valueInBaseCurrency: number;
}

2
libs/common/src/lib/interfaces/index.ts

@ -1,4 +1,3 @@
import type { AccessSettings } from './access-settings.interface';
import type { Access } from './access.interface';
import type { AccountBalance } from './account-balance.interface';
import type { Activity, ActivityError } from './activities.interface';
@ -102,7 +101,6 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface';
export {
Access,
AccessSettings,
AccessTokenResponse,
AccountBalance,
AccountBalancesResponse,

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

Loading…
Cancel
Save