From c9cc020540bbe47dfa99d13470f6e94aea233ea4 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:54:54 +0200 Subject: [PATCH] Task/extend accounts endpoint by filters (#7748) * Extend accounts endpoint by accounts, assetClasses and tags filters * Update changelog --- CHANGELOG.md | 4 + .../api/src/app/account/account.controller.ts | 22 ++- .../src/app/account/get-all-accounts.dto.ts | 9 ++ .../src/app/activities/activities.service.ts | 4 +- .../app/portfolio/portfolio.service.spec.ts | 136 +++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 114 +++++++++++++-- 6 files changed, 268 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/app/account/get-all-accounts.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d448b2c9b..ddc818959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Extended the `GET api/v1/account` endpoint by the filters `accounts`, `assetClasses` and `tags` + ### Fixed - Fixed the redaction of `fee`, `quantity`, `value` and `valueInBaseCurrency` in the latest activities of the public page (experimental) diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 8fc1714db..f2ef1a959 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -36,6 +36,7 @@ import { Account as AccountModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccountService } from './account.service'; +import { GetAllAccountsDto } from './get-all-accounts.dto'; @Controller('account') export class AccountController { @@ -85,14 +86,23 @@ export class AccountController { @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( @Impersonation() { userId }: ImpersonationContext, - @Query('dataSource') filterByDataSource?: string, - @Query('query') filterBySearchQuery?: string, - @Query('symbol') filterBySymbol?: string + @Query() + { + accounts, + assetClasses, + dataSource, + query, + symbol, + tags + }: GetAllAccountsDto ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ - filterByDataSource, - filterBySearchQuery, - filterBySymbol + filterByAccounts: accounts, + filterByAssetClasses: assetClasses, + filterByDataSource: dataSource, + filterBySearchQuery: query, + filterBySymbol: symbol, + filterByTags: tags }); return this.portfolioService.getAccountsWithAggregations({ diff --git a/apps/api/src/app/account/get-all-accounts.dto.ts b/apps/api/src/app/account/get-all-accounts.dto.ts new file mode 100644 index 000000000..61a248019 --- /dev/null +++ b/apps/api/src/app/account/get-all-accounts.dto.ts @@ -0,0 +1,9 @@ +import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; + +import { IsOptional, IsString } from 'class-validator'; + +export class GetAllAccountsDto extends FilterDto { + @IsOptional() + @IsString() + query?: string; +} diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 3627de912..a40f6f952 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -1031,8 +1031,8 @@ export class ActivitiesService { }, { OR: [ - { assetProfileOverrides: { is: null } }, - { assetProfileOverrides: { assetClass: null } } + { assetProfileOverrides: { assetClass: null } }, + { assetProfileOverrides: { is: null } } ] } ] diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 712117832..227262c40 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -13,11 +13,12 @@ import { TAG_ID_EMERGENCY_FUND, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, + Filter, PortfolioSummary } from '@ghostfolio/common/interfaces'; import { AccountWithBalance } from '@ghostfolio/common/types'; -import { DataSource } from '@prisma/client'; +import { AssetClass, DataSource, Prisma } from '@prisma/client'; import { Big } from 'big.js'; import { randomUUID } from 'node:crypto'; @@ -114,6 +115,139 @@ describe('PortfolioService', () => { ); }); + describe('getAccounts', () => { + const tagId = 'd6bf8b4a-8ef9-4b3f-9e2c-0a1b2c3d4e5f'; + + const whereTagsOfActivity = { some: { OR: [{ id: tagId }] } }; + + const whereActivityOfTag = { + OR: [ + { account: { tags: { some: { OR: [{ tagId }] } } } }, + { tags: whereTagsOfActivity } + ] + }; + + const whereActivityOfAssetClass = { + SymbolProfile: { + OR: [ + { + AND: [ + { OR: [{ assetClass: AssetClass.EQUITY }] }, + { + OR: [ + { assetProfileOverrides: { assetClass: null } }, + { assetProfileOverrides: { is: null } } + ] + } + ] + }, + { + assetProfileOverrides: { OR: [{ assetClass: AssetClass.EQUITY }] } + } + ] + } + }; + + const getAccountsQuery = async (filters?: Filter[]) => { + const accountsSpy = jest + .spyOn(accountService, 'accounts') + .mockResolvedValue([]); + + jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({ + accounts: {} + } as unknown as Awaited>); + + jest.spyOn(userService, 'user').mockResolvedValue(null); + + await portfolioService.getAccounts({ filters, userId: userDummyData.id }); + + const { include, where } = accountsSpy.mock.calls[0][0]; + + return { + where, + whereOfActivities: ( + include.activities as { where?: Prisma.OrderWhereInput } + ).where + }; + }; + + it('should not restrict the accounts and their activities without a filter', async () => { + const { where, whereOfActivities } = await getAccountsQuery(); + + expect(where).toEqual({ userId: userDummyData.id }); + expect(whereOfActivities).toBeUndefined(); + }); + + it('should match an account with the tag filter by its own tags or by the tags of its activities', async () => { + const { where, whereOfActivities } = await getAccountsQuery([ + { id: tagId, type: 'TAG' } + ]); + + expect(where.AND).toEqual([ + { + OR: [ + { activities: { some: { tags: whereTagsOfActivity } } }, + { tags: { some: { OR: [{ tagId }] } } } + ] + } + ]); + + expect(whereOfActivities).toEqual({ AND: [whereActivityOfTag] }); + }); + + it('should restrict the accounts and their activities to the asset class filter', async () => { + const { where, whereOfActivities } = await getAccountsQuery([ + { id: AssetClass.EQUITY, type: 'ASSET_CLASS' } + ]); + + expect(where.AND).toEqual([ + { activities: { some: whereActivityOfAssetClass } } + ]); + + expect(whereOfActivities).toEqual({ AND: [whereActivityOfAssetClass] }); + }); + + it('should restrict the accounts and their activities to the holding filter', async () => { + const whereActivityOfHolding = { + SymbolProfile: { + AND: [{ dataSource: DataSource.YAHOO }, { symbol: 'AAPL' }] + } + }; + + const { where, whereOfActivities } = await getAccountsQuery([ + { id: DataSource.YAHOO, type: 'DATA_SOURCE' }, + { id: 'AAPL', type: 'SYMBOL' } + ]); + + expect(where.AND).toEqual([ + { activities: { some: whereActivityOfHolding } } + ]); + + expect(whereOfActivities).toEqual({ AND: [whereActivityOfHolding] }); + }); + + it('should combine the filters of different types with a logical and', async () => { + const { where, whereOfActivities } = await getAccountsQuery([ + { id: AssetClass.EQUITY, type: 'ASSET_CLASS' }, + { id: tagId, type: 'TAG' } + ]); + + expect(where.AND).toEqual([ + { activities: { some: whereActivityOfAssetClass } }, + { + OR: [ + { activities: { some: { tags: whereTagsOfActivity } } }, + { tags: { some: { OR: [{ tagId }] } } } + ] + } + ]); + + expect(whereOfActivities).toEqual({ + AND: [whereActivityOfAssetClass, whereActivityOfTag] + }); + }); + }); + describe('getAggregatedMarkets', () => { const getAggregatedMarkets = (holdings: object[]) => { return ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e7ea9a898..5075f00e7 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -143,30 +143,119 @@ export class PortfolioService { const where: Prisma.AccountWhereInput = { userId }; const { - ACCOUNT: [filterByAccount] = [], + ACCOUNT: filtersByAccount = [], + ASSET_CLASS: filtersByAssetClass = [], DATA_SOURCE: [filterByDataSource] = [], - SYMBOL: [filterBySymbol] = [] + SYMBOL: [filterBySymbol] = [], + TAG: filtersByTag = [] } = groupBy(filters, ({ type }) => { return type; }); - if (filterByAccount) { - where.id = filterByAccount.id; + if (filtersByAccount.length > 0) { + where.id = { + in: filtersByAccount.map(({ id }) => { + return id; + }) + }; + } + + const whereAccountConditions: Prisma.AccountWhereInput[] = []; + const whereActivityConditions: Prisma.OrderWhereInput[] = []; + + if (filtersByAssetClass.length > 0) { + const whereAssetClassConditions = filtersByAssetClass.map(({ id }) => { + return { assetClass: AssetClass[id] }; + }); + + const whereActivityOfAssetClass: Prisma.OrderWhereInput = { + SymbolProfile: { + OR: [ + { + AND: [ + { OR: whereAssetClassConditions }, + { + OR: [ + { assetProfileOverrides: { assetClass: null } }, + { assetProfileOverrides: { is: null } } + ] + } + ] + }, + { + assetProfileOverrides: { OR: whereAssetClassConditions } + } + ] + } + }; + + whereAccountConditions.push({ + activities: { some: whereActivityOfAssetClass } + }); + + whereActivityConditions.push(whereActivityOfAssetClass); } if (filterByDataSource && filterBySymbol) { - where.activities = { + const whereActivityOfHolding: Prisma.OrderWhereInput = { + SymbolProfile: { + AND: [ + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } + ] + } + }; + + whereAccountConditions.push({ + activities: { some: whereActivityOfHolding } + }); + + whereActivityConditions.push(whereActivityOfHolding); + } + + if (filtersByTag.length > 0) { + const whereTagsOfAccount: Prisma.TagsOnAccountsListRelationFilter = { some: { - SymbolProfile: { - AND: [ - { dataSource: filterByDataSource.id as DataSource }, - { symbol: filterBySymbol.id } - ] - } + OR: filtersByTag.map(({ id }) => { + return { tagId: id }; + }) + } + }; + + const whereTagsOfActivity: Prisma.TagListRelationFilter = { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) } }; + + const whereActivityOfTag: Prisma.OrderWhereInput = { + OR: [ + { account: { tags: whereTagsOfAccount } }, + { tags: whereTagsOfActivity } + ] + }; + + whereAccountConditions.push({ + OR: [ + { activities: { some: { tags: whereTagsOfActivity } } }, + { tags: whereTagsOfAccount } + ] + }); + + whereActivityConditions.push(whereActivityOfTag); } + if (whereAccountConditions.length > 0) { + where.AND = whereAccountConditions; + } + + const whereActivity: Prisma.OrderWhereInput = + whereActivityConditions.length > 0 + ? { AND: whereActivityConditions } + : undefined; + const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => { return type !== 'SEARCH_QUERY'; }); @@ -186,7 +275,8 @@ export class PortfolioService { id: TAG_ID_DRAFT } } - } + }, + where: whereActivity }, platform: true, tags: true