Browse Source

Extend accounts endpoint by accounts, assetClasses and tags filters

pull/7748/head
Thomas Kaul 15 hours ago
parent
commit
0035d625ee
  1. 22
      apps/api/src/app/account/account.controller.ts
  2. 9
      apps/api/src/app/account/get-all-accounts.dto.ts
  3. 134
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  4. 98
      apps/api/src/app/portfolio/portfolio.service.ts

22
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { GetAllAccountsDto } from './get-all-accounts.dto';
@Controller('account') @Controller('account')
export class AccountController { export class AccountController {
@ -85,14 +86,23 @@ export class AccountController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@Query('dataSource') filterByDataSource?: string, @Query()
@Query('query') filterBySearchQuery?: string, {
@Query('symbol') filterBySymbol?: string accounts,
assetClasses,
dataSource,
query,
symbol,
tags
}: GetAllAccountsDto
): Promise<AccountsResponse> { ): Promise<AccountsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource, filterByAccounts: accounts,
filterBySearchQuery, filterByAssetClasses: assetClasses,
filterBySymbol filterByDataSource: dataSource,
filterBySearchQuery: query,
filterBySymbol: symbol,
filterByTags: tags
}); });
return this.portfolioService.getAccountsWithAggregations({ return this.portfolioService.getAccountsWithAggregations({

9
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;
}

134
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 { parseDate } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Filter,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types'; import { AccountWithBalance } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client'; import { AssetClass, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -114,6 +115,137 @@ describe('PortfolioService', () => {
); );
}); });
describe('getAccounts', () => {
const tagId = 'd6bf8b4a-8ef9-4b3f-9e2c-0a1b2c3d4e5f';
const whereActivityOfTag = {
OR: [
{ tags: { some: { OR: [{ id: tagId }] } } },
{ account: { tags: { some: { OR: [{ tagId }] } } } }
]
};
const whereActivityOfAssetClass = {
SymbolProfile: {
OR: [
{
AND: [
{ OR: [{ assetClass: AssetClass.EQUITY }] },
{
OR: [
{ assetProfileOverrides: { is: null } },
{ assetProfileOverrides: { assetClass: 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<ReturnType<typeof portfolioService.getDetails>>);
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: [
{ tags: { some: { OR: [{ tagId }] } } },
{ activities: { some: whereActivityOfTag } }
]
}
]);
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: [
{ tags: { some: { OR: [{ tagId }] } } },
{ activities: { some: whereActivityOfTag } }
]
}
]);
expect(whereOfActivities).toEqual({
AND: [whereActivityOfAssetClass, whereActivityOfTag]
});
});
});
describe('getAggregatedMarkets', () => { describe('getAggregatedMarkets', () => {
const getAggregatedMarkets = (holdings: object[]) => { const getAggregatedMarkets = (holdings: object[]) => {
return ( return (

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

@ -144,8 +144,10 @@ export class PortfolioService {
const { const {
ACCOUNT: [filterByAccount] = [], ACCOUNT: [filterByAccount] = [],
ASSET_CLASS: filtersByAssetClass = [],
DATA_SOURCE: [filterByDataSource] = [], DATA_SOURCE: [filterByDataSource] = [],
SYMBOL: [filterBySymbol] = [] SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag = []
} = groupBy(filters, ({ type }) => { } = groupBy(filters, ({ type }) => {
return type; return type;
}); });
@ -154,19 +156,106 @@ export class PortfolioService {
where.id = filterByAccount.id; where.id = filterByAccount.id;
} }
const whereAccountConditions: Prisma.AccountWhereInput[] = [];
const whereActivityConditions: Prisma.OrderWhereInput[] = [];
if (filtersByAssetClass.length > 0) {
const whereActivityOfAssetClass: Prisma.OrderWhereInput = {
SymbolProfile: {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
OR: [
{ assetProfileOverrides: { is: null } },
{ assetProfileOverrides: { assetClass: null } }
]
}
]
},
{
assetProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
}
};
whereAccountConditions.push({
activities: { some: whereActivityOfAssetClass }
});
whereActivityConditions.push(whereActivityOfAssetClass);
}
if (filterByDataSource && filterBySymbol) { if (filterByDataSource && filterBySymbol) {
where.activities = { const whereActivityOfHolding: Prisma.OrderWhereInput = {
some: {
SymbolProfile: { SymbolProfile: {
AND: [ AND: [
{ dataSource: filterByDataSource.id as DataSource }, { dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol.id } { symbol: filterBySymbol.id }
] ]
} }
};
whereAccountConditions.push({
activities: { some: whereActivityOfHolding }
});
whereActivityConditions.push(whereActivityOfHolding);
}
if (filtersByTag.length > 0) {
const whereTagsOfAccount: Prisma.TagsOnAccountsListRelationFilter = {
some: {
OR: filtersByTag.map(({ id }) => {
return { tagId: id };
})
}
};
const whereActivityOfTag: Prisma.OrderWhereInput = {
OR: [
{
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
} }
}
},
{ account: { tags: whereTagsOfAccount } }
]
}; };
whereAccountConditions.push({
OR: [
{ tags: whereTagsOfAccount },
{ activities: { some: whereActivityOfTag } }
]
});
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 }) => { const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY'; return type !== 'SEARCH_QUERY';
}); });
@ -186,7 +275,8 @@ export class PortfolioService {
id: TAG_ID_DRAFT id: TAG_ID_DRAFT
} }
} }
} },
where: whereActivity
}, },
platform: true, platform: true,
tags: true tags: true

Loading…
Cancel
Save