From c542997d27990365b607c7a6c9fe4bc8835a55c8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:32:41 +0200 Subject: [PATCH] Task/extract public portfolio endpoint logic to service (#7699) Extract logic to service --- .../app/endpoints/public/public.controller.ts | 215 +----------------- .../src/app/endpoints/public/public.module.ts | 2 + .../app/endpoints/public/public.service.ts | 213 +++++++++++++++++ 3 files changed, 221 insertions(+), 209 deletions(-) create mode 100644 apps/api/src/app/endpoints/public/public.service.ts diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 6093e5f87..66c4a7bef 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -1,44 +1,14 @@ -import { AccessService } from '@ghostfolio/api/app/access/access.service'; -import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; -import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -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 { - Controller, - Get, - HttpException, - Param, - UseInterceptors -} from '@nestjs/common'; -import { - AssetClass, - AssetSubClass, - Type as ActivityType -} from '@prisma/client'; -import { Big } from 'big.js'; -import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; + +import { PublicService } from './public.service'; @Controller('public') export class PublicController { - public constructor( - private readonly accessService: AccessService, - private readonly activitiesService: ActivitiesService, - private readonly configurationService: ConfigurationService, - private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly portfolioService: PortfolioService, - private readonly userService: UserService - ) {} + public constructor(private readonly publicService: PublicService) {} @Get(':accessId/portfolio') @UseInterceptors(RedactValuesInResponseInterceptor) @@ -46,179 +16,6 @@ export class PublicController { public async getPublicPortfolio( @Param('accessId') accessId: string ): Promise { - const access = await this.accessService.access({ - granteeUserId: null, - id: accessId - }); - - if (!access) { - throw new HttpException( - getReasonPhrase(StatusCodes.NOT_FOUND), - StatusCodes.NOT_FOUND - ); - } - - let hasDetails = true; - - const user = await this.userService.user({ - id: access.userId - }); - - if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - hasDetails = user?.subscription?.type === SubscriptionType.Premium; - } - - const { filters } = (access.settings ?? {}) as AccessSettings; - - const [ - { createdAt, holdings, markets }, - { performance: performance1d }, - { performance: performanceMax }, - { performance: performanceYtd } - ] = await Promise.all([ - this.portfolioService.getDetails({ - filters, - userId: user.id, - withMarkets: true - }), - ...['1d', 'max', 'ytd'].map((dateRange) => { - return this.portfolioService.getPerformance({ - dateRange, - filters, - userId: user.id - }); - }) - ]); - - const { activities } = await this.activitiesService.getActivities({ - filters, - sortColumn: 'date', - sortDirection: 'desc', - take: 10, - types: [ActivityType.BUY, ActivityType.SELL], - userCurrency: user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, - userId: user.id, - withExcludedAccountsAndActivities: false - }); - - // Experimental - const latestActivities = this.configurationService.get( - 'ENABLE_FEATURE_SUBSCRIPTION' - ) - ? [] - : activities.map( - ({ - assetProfile, - currency, - date, - fee, - quantity, - type, - unitPrice, - value, - valueInBaseCurrency - }) => { - return { - assetProfile, - currency, - date, - fee, - quantity, - type, - unitPrice, - value, - valueInBaseCurrency - }; - } - ); - - Object.values(markets ?? {}).forEach((market) => { - delete market.valueInBaseCurrency; - }); - - const publicPortfolioResponse: PublicPortfolioResponse = { - createdAt, - hasDetails, - latestActivities, - markets, - alias: access.alias, - holdings: {}, - performance: { - '1d': { - relativeChange: - performance1d.netPerformancePercentageWithCurrencyEffect - }, - max: { - relativeChange: - performanceMax.netPerformancePercentageWithCurrencyEffect - }, - ytd: { - relativeChange: - performanceYtd.netPerformancePercentageWithCurrencyEffect - } - } - }; - - const totalValue = getSum( - Object.values(holdings).map(({ assetProfile, marketPrice, quantity }) => { - return new Big( - this.exchangeRateDataService.toCurrency( - quantity * marketPrice, - assetProfile.currency, - user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY - ) - ); - }) - ).toNumber(); - - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { - publicPortfolioResponse.holdings[symbol] = { - allocationInPercentage: - portfolioPosition.valueInBaseCurrency / totalValue, - assetProfile: { - ...portfolioPosition.assetProfile, - assetClass: - hasDetails || - portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetProfile.assetClass - : undefined, - assetClassLabel: - hasDetails || - portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetProfile.assetClassLabel - : undefined, - assetSubClass: - hasDetails || - portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetProfile.assetSubClass - : undefined, - assetSubClassLabel: - hasDetails || - portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetProfile.assetSubClassLabel - : undefined, - holdings: portfolioPosition.assetProfile.holdings?.map( - ({ allocationInPercentage, name }) => { - return { allocationInPercentage, name }; - } - ), - ...(hasDetails - ? {} - : { - countries: [], - currency: undefined, - holdings: [], - sectors: [] - }) - }, - dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, - markets: hasDetails ? portfolioPosition.markets : undefined, - netPerformancePercentWithCurrencyEffect: - portfolioPosition.netPerformancePercentWithCurrencyEffect, - valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue - }; - } - - return publicPortfolioResponse; + return this.publicService.getPublicPortfolio(accessId); } } diff --git a/apps/api/src/app/endpoints/public/public.module.ts b/apps/api/src/app/endpoints/public/public.module.ts index b992694c5..d94f350c3 100644 --- a/apps/api/src/app/endpoints/public/public.module.ts +++ b/apps/api/src/app/endpoints/public/public.module.ts @@ -23,6 +23,7 @@ import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; import { PublicController } from './public.controller'; +import { PublicService } from './public.service'; @Module({ controllers: [PublicController], @@ -49,6 +50,7 @@ import { PublicController } from './public.controller'; CurrentRateService, PortfolioCalculatorFactory, PortfolioService, + PublicService, RulesService ] }) diff --git a/apps/api/src/app/endpoints/public/public.service.ts b/apps/api/src/app/endpoints/public/public.service.ts new file mode 100644 index 000000000..c27579228 --- /dev/null +++ b/apps/api/src/app/endpoints/public/public.service.ts @@ -0,0 +1,213 @@ +import { AccessService } from '@ghostfolio/api/app/access/access.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +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 { 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 { HttpException, Injectable } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + Type as ActivityType +} from '@prisma/client'; +import { Big } from 'big.js'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class PublicService { + public constructor( + private readonly accessService: AccessService, + private readonly activitiesService: ActivitiesService, + private readonly configurationService: ConfigurationService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly portfolioService: PortfolioService, + private readonly userService: UserService + ) {} + + public async getPublicPortfolio( + accessId: string + ): Promise { + const access = await this.accessService.access({ + granteeUserId: null, + id: accessId + }); + + if (!access) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + let hasDetails = true; + + const user = await this.userService.user({ + id: access.userId + }); + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + hasDetails = user?.subscription?.type === SubscriptionType.Premium; + } + + const { filters } = (access.settings ?? {}) as AccessSettings; + + const [ + { createdAt, holdings, markets }, + { performance: performance1d }, + { performance: performanceMax }, + { performance: performanceYtd } + ] = await Promise.all([ + this.portfolioService.getDetails({ + filters, + userId: user.id, + withMarkets: true + }), + ...['1d', 'max', 'ytd'].map((dateRange) => { + return this.portfolioService.getPerformance({ + dateRange, + filters, + userId: user.id + }); + }) + ]); + + const { activities } = await this.activitiesService.getActivities({ + filters, + sortColumn: 'date', + sortDirection: 'desc', + take: 10, + types: [ActivityType.BUY, ActivityType.SELL], + userCurrency: user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, + userId: user.id, + withExcludedAccountsAndActivities: false + }); + + // Experimental + const latestActivities = this.configurationService.get( + 'ENABLE_FEATURE_SUBSCRIPTION' + ) + ? [] + : activities.map( + ({ + assetProfile, + currency, + date, + fee, + quantity, + type, + unitPrice, + value, + valueInBaseCurrency + }) => { + return { + assetProfile, + currency, + date, + fee, + quantity, + type, + unitPrice, + value, + valueInBaseCurrency + }; + } + ); + + Object.values(markets ?? {}).forEach((market) => { + delete market.valueInBaseCurrency; + }); + + const publicPortfolioResponse: PublicPortfolioResponse = { + createdAt, + hasDetails, + latestActivities, + markets, + alias: access.alias, + holdings: {}, + performance: { + '1d': { + relativeChange: + performance1d.netPerformancePercentageWithCurrencyEffect + }, + max: { + relativeChange: + performanceMax.netPerformancePercentageWithCurrencyEffect + }, + ytd: { + relativeChange: + performanceYtd.netPerformancePercentageWithCurrencyEffect + } + } + }; + + const totalValue = getSum( + Object.values(holdings).map(({ assetProfile, marketPrice, quantity }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + quantity * marketPrice, + assetProfile.currency, + user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY + ) + ); + }) + ).toNumber(); + + for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + publicPortfolioResponse.holdings[symbol] = { + allocationInPercentage: + portfolioPosition.valueInBaseCurrency / totalValue, + assetProfile: { + ...portfolioPosition.assetProfile, + assetClass: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClass + : undefined, + assetClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClassLabel + : undefined, + assetSubClass: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClass + : undefined, + assetSubClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClassLabel + : undefined, + holdings: portfolioPosition.assetProfile.holdings?.map( + ({ allocationInPercentage, name }) => { + return { allocationInPercentage, name }; + } + ), + ...(hasDetails + ? {} + : { + countries: [], + currency: undefined, + holdings: [], + sectors: [] + }) + }, + dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, + markets: hasDetails ? portfolioPosition.markets : undefined, + netPerformancePercentWithCurrencyEffect: + portfolioPosition.netPerformancePercentWithCurrencyEffect, + valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue + }; + } + + return publicPortfolioResponse; + } +}