diff --git a/CHANGELOG.md b/CHANGELOG.md index d1dc4c74c..f37d01aa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for routing outgoing requests through a proxy per domain 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 diff --git a/README.md b/README.md index de4b4d828..b72f03cd3 100644 --- a/README.md +++ b/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 | | -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index b87f91a79..805710396 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -1,4 +1,8 @@ /* eslint-disable */ + +// Run tests in UTC for deterministic date-based calculations +process.env.TZ = 'UTC'; + export default { displayName: 'api', diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 28b459203..35b1d485b 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/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 } from '@ghostfolio/common/interfaces'; +import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -46,13 +46,14 @@ export class AccessController { }); return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions }) => { + ({ alias, granteeUser, id, permissions, settings }) => { if (granteeUser) { return { alias, id, permissions, grantee: granteeUser?.id, + settings: settings as AccessSettings, type: 'PRIVATE' }; } @@ -62,6 +63,7 @@ export class AccessController { id, permissions, grantee: 'Public', + settings: settings as AccessSettings, type: 'PUBLIC' }; } @@ -91,6 +93,7 @@ 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 { @@ -158,7 +161,8 @@ export class AccessController { granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, - permissions: data.permissions + permissions: data.permissions, + settings: this.accessService.buildSettings(data.filters) }, where: { id } }); diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 70e46dc36..e50a6c7d0 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -1,4 +1,5 @@ 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'; @@ -39,6 +40,12 @@ 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 { return this.prismaService.access.create({ data diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 08e49704b..d06e3b5fe 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/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 { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + AccessSettings, + PublicPortfolioResponse +} from '@ghostfolio/common/interfaces'; import { Controller, Get, HttpException, - Inject, Param, UseInterceptors } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; import { AssetClass, AssetSubClass, @@ -37,7 +37,6 @@ 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 ) {} @@ -66,6 +65,8 @@ export class PublicController { hasDetails = user.subscription.type === SubscriptionType.Premium; } + const { filters } = (access.settings ?? {}) as AccessSettings; + const [ { createdAt, holdings, markets }, { performance: performance1d }, @@ -73,6 +74,7 @@ export class PublicController { { performance: performanceYtd } ] = await Promise.all([ this.portfolioService.getDetails({ + filters, impersonationId: access.userId, userId: user.id, withMarkets: true @@ -80,6 +82,7 @@ export class PublicController { ...['1d', 'max', 'ytd'].map((dateRange) => { return this.portfolioService.getPerformance({ dateRange, + filters, impersonationId: undefined, userId: user.id }); @@ -87,6 +90,7 @@ export class PublicController { ]); const { activities } = await this.activitiesService.getActivities({ + filters, sortColumn: 'date', sortDirection: 'desc', take: 10, @@ -160,8 +164,7 @@ export class PublicController { this.exchangeRateDataService.toCurrency( quantity * marketPrice, assetProfile.currency, - this.request.user?.settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY + user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY ) ); }) @@ -193,6 +196,11 @@ export class PublicController { portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH ? portfolioPosition.assetProfile.assetSubClassLabel : undefined, + holdings: portfolioPosition.assetProfile.holdings?.map( + ({ allocationInPercentage, name }) => { + return { allocationInPercentage, name }; + } + ), ...(hasDetails ? {} : { diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 9f69dc39b..64a8bf4ef 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -13,7 +13,8 @@ import { SymbolProfile } from '@prisma/client'; @Injectable() export class TrackinsightDataEnhancerService implements DataEnhancerInterface { - private static baseUrl = 'https://www.trackinsight.com/data-api'; + private static baseUrl = 'https://www.trackinsight.com'; + private static countriesMapping = { 'Republic of Korea': 'KR', 'Russian Federation': 'RU', @@ -21,7 +22,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { USA: 'US', 'Virgin Islands, British': 'VG' }; + private static holdingsWeightTreshold = 0.85; + private static sectorsMapping: Record = { 'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Staples': 'Consumer Defensive', @@ -74,7 +77,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { const profile = await this.fetchService .fetch( - `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`, + `${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${trackinsightSymbol}.json`, { signal: AbortSignal.timeout(requestTimeout) } @@ -98,7 +101,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { const holdings = await this.fetchService .fetch( - `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`, + `${TrackinsightDataEnhancerService.baseUrl}/data-api/holdings/${trackinsightSymbol}.json`, { signal: AbortSignal.timeout(requestTimeout) } @@ -191,7 +194,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { }) { return this.fetchService .fetch( - `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`, + `${TrackinsightDataEnhancerService.baseUrl}/search-api/search_v2/${symbol}/_/ticker/default/0/3`, { signal: AbortSignal.timeout(requestTimeout) } diff --git a/apps/client/project.json b/apps/client/project.json index 38887ca8a..9b0e8dafe 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -26,6 +26,10 @@ "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" @@ -114,6 +118,10 @@ "baseHref": "/it/", "localize": ["it"] }, + "development-ja": { + "baseHref": "/ja/", + "localize": ["ja"] + }, "development-ko": { "baseHref": "/ko/", "localize": ["ko"] @@ -239,6 +247,9 @@ "development-it": { "buildTarget": "client:build:development-it" }, + "development-ja": { + "buildTarget": "client:build:development-ja" + }, "development-ko": { "buildTarget": "client:build:development-ko" }, @@ -278,6 +289,7 @@ "messages.es.xlf", "messages.fr.xlf", "messages.it.xlf", + "messages.ja.xlf", "messages.ko.xlf", "messages.nl.xlf", "messages.pl.xlf", diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 506190cfb..9dd6c8374 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -2,7 +2,7 @@ - diff --git a/apps/client/src/app/components/footer/footer.component.html b/apps/client/src/app/components/footer/footer.component.html index c839db8f1..5f0016317 100644 --- a/apps/client/src/app/components/footer/footer.component.html +++ b/apps/client/src/app/components/footer/footer.component.html @@ -141,6 +141,13 @@
  • Italiano
  • +
  • Korean (한국어) { + this.accounts = accounts; + this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false; + this.tags = getTagFilters(tags); + + this.changeDetectorRef.markForCheck(); + }); + this.accessForm .get('type') ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) @@ -102,6 +143,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { if (accessType === 'PRIVATE') { granteeUserIdControl?.setValidators(Validators.required); + this.accessForm.get('filters')?.setValue(null); } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); @@ -114,6 +156,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.changeDetectorRef.markForCheck(); }); + + this.loadHoldings(); } protected onCancel() { @@ -128,9 +172,18 @@ 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] }; @@ -164,6 +217,19 @@ 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; @@ -171,8 +237,11 @@ 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] @@ -206,4 +275,14 @@ 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)); + } } diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 93614b55a..1736aa9fc 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -59,6 +59,18 @@ } + @if (canApplyFilters) { +

    Portfolio Filters

    + + }
  • Alias + {{ element.alias }}