import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { OrderService } from '@ghostfolio/api/app/order/order.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 { getSum } from '@ghostfolio/common/helper'; import { AccessSettings, Filter, 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 { Type as ActivityType, AssetSubClass } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @Controller('public') export class PublicController { public constructor( private readonly accessService: AccessService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly orderService: OrderService, private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService ) {} @Get(':accessId/portfolio') @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPublicPortfolio( @Param('accessId') accessId: string ): Promise { const access = await this.accessService.access({ 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 === 'Premium'; } // Get filter configuration from access settings const accessSettings = (access.settings ?? {}) as AccessSettings; const accessFilter = accessSettings.filter; // Convert access filter to portfolio filters const portfolioFilters: Filter[] = []; if (accessFilter) { // Add account filters if (accessFilter.accountIds && accessFilter.accountIds.length > 0) { portfolioFilters.push( ...accessFilter.accountIds.map((accountId) => ({ id: accountId, type: 'ACCOUNT' as const })) ); } // Add asset class filters if (accessFilter.assetClasses && accessFilter.assetClasses.length > 0) { portfolioFilters.push( ...accessFilter.assetClasses.map((assetClass) => ({ id: assetClass, type: 'ASSET_CLASS' as const })) ); } // Add tag filters if (accessFilter.tagIds && accessFilter.tagIds.length > 0) { portfolioFilters.push( ...accessFilter.tagIds.map((tagId) => ({ id: tagId, type: 'TAG' as const })) ); } // Add holding filters (symbol + dataSource) // Each holding needs both DATA_SOURCE and SYMBOL filters if (accessFilter.holdings && accessFilter.holdings.length > 0) { accessFilter.holdings.forEach((holding) => { portfolioFilters.push( { id: holding.dataSource, type: 'DATA_SOURCE' as const }, { id: holding.symbol, type: 'SYMBOL' as const } ); }); } } const [ { createdAt, holdings, markets }, { performance: performance1d }, { performance: performanceMax }, { performance: performanceYtd } ] = await Promise.all([ this.portfolioService.getDetails({ filters: portfolioFilters.length > 0 ? portfolioFilters : undefined, impersonationId: access.userId, userId: user.id, withMarkets: true }), ...['1d', 'max', 'ytd'].map((dateRange) => { return this.portfolioService.getPerformance({ dateRange, filters: portfolioFilters.length > 0 ? portfolioFilters : undefined, impersonationId: undefined, userId: user.id }); }) ]); // Filter out only the base currency cash holdings const baseCurrency = user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; const filteredHoldings = Object.fromEntries( Object.entries(holdings).filter(([, holding]) => { // Remove only cash holdings that match the base currency const isCash = holding.assetSubClass === AssetSubClass.CASH; const isBaseCurrency = holding.symbol === baseCurrency; return !(isCash && isBaseCurrency); }) ); // Use filters for activities, but exclude DATA_SOURCE/SYMBOL filters // if there are multiple holdings (the service can't handle multiple symbol filters) const hasMultipleHoldingFilters = accessFilter?.holdings && accessFilter.holdings.length > 1; const activityFilters = portfolioFilters.filter((filter) => { // Always include ACCOUNT, ASSET_CLASS, TAG filters if ( filter.type === 'ACCOUNT' || filter.type === 'ASSET_CLASS' || filter.type === 'TAG' ) { return true; } // Include DATA_SOURCE and SYMBOL only if there's a single holding filter if ( !hasMultipleHoldingFilters && (filter.type === 'DATA_SOURCE' || filter.type === 'SYMBOL') ) { return true; } return false; }); const { activities } = await this.orderService.getOrders({ filters: activityFilters.length > 0 ? activityFilters : undefined, includeDrafts: false, sortColumn: 'date', sortDirection: 'desc', take: hasMultipleHoldingFilters ? 1000 : 10, // Get more if we need to filter manually types: [ActivityType.BUY, ActivityType.SELL], userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, userId: user.id, withExcludedAccountsAndActivities: false }); // If multiple holdings, filter activities manually let filteredActivities = activities; if (hasMultipleHoldingFilters && accessFilter.holdings) { filteredActivities = activities.filter((activity) => { return accessFilter.holdings.some( (holding) => activity.SymbolProfile.dataSource === holding.dataSource && activity.SymbolProfile.symbol === holding.symbol ); }); } // Take only the latest 10 activities after filtering const latestActivitiesData = filteredActivities.slice(0, 10); // Experimental const latestActivities = this.configurationService.get( 'ENABLE_FEATURE_SUBSCRIPTION' ) ? [] : latestActivitiesData.map( ({ currency, date, fee, quantity, SymbolProfile, type, unitPrice, value, valueInBaseCurrency }) => { return { currency, date, fee, quantity, SymbolProfile, type, unitPrice, value, valueInBaseCurrency }; } ); Object.values(markets ?? {}).forEach((market) => { delete market.valueInBaseCurrency; }); const publicPortfolioResponse: PublicPortfolioResponse = { alias: access.alias, createdAt, hasDetails, latestActivities, holdings: {}, markets, performance: { '1d': { relativeChange: performance1d.netPerformancePercentageWithCurrencyEffect }, max: { relativeChange: performanceMax.netPerformancePercentageWithCurrencyEffect }, ytd: { relativeChange: performanceYtd.netPerformancePercentageWithCurrencyEffect } } }; const totalValue = getSum( Object.values(filteredHoldings).map( ({ currency, marketPrice, quantity }) => { return new Big( this.exchangeRateDataService.toCurrency( quantity * marketPrice, currency, this.request.user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY ) ); } ) ).toNumber(); for (const [symbol, portfolioPosition] of Object.entries( filteredHoldings )) { publicPortfolioResponse.holdings[symbol] = { allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, assetClass: hasDetails ? portfolioPosition.assetClass : undefined, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, dataSource: portfolioPosition.dataSource, dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, markets: hasDetails ? portfolioPosition.markets : undefined, name: portfolioPosition.name, netPerformancePercentWithCurrencyEffect: portfolioPosition.netPerformancePercentWithCurrencyEffect, sectors: hasDetails ? portfolioPosition.sectors : [], symbol: portfolioPosition.symbol, url: portfolioPosition.url, valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue }; } return publicPortfolioResponse; } }