From 15fde30f9be435e9d9b236f3583f22c996015e11 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:53:47 +0200 Subject: [PATCH] Task/refactor subscription flag of asset profiles (#8264) * Replace result extension with single query for subscription flag of asset profiles * Update changelog --- CHANGELOG.md | 1 + .../asset-profiles.service.spec.ts | 84 +++++++++++++++++- .../asset-profiles/asset-profiles.service.ts | 86 ++++++++++--------- 3 files changed, 126 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a58b2a19a..574c1e07ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved the performance of the historical market data table of the admin control panel by counting the market data per asset profile +- Improved the performance of the historical market data table of the admin control panel by querying subscription usage in a single database query - Hardened the `POST api/v1/import` endpoint by hiding internal error messages ## 3.91.0 - 2026-10-23 diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts index e6c483ebf6..b76cca57cc 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts @@ -1,6 +1,9 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { NotFoundException } from '@nestjs/common'; @@ -13,6 +16,7 @@ describe('AssetProfilesService', () => { let assetProfilesService: AssetProfilesService; let deleteById: jest.Mock; let emit: jest.Mock; + let findMany: jest.Mock; let finished: jest.Mock; let gatherSymbol: jest.Mock; let getUserIdsBySymbolProfileId: jest.Mock; @@ -21,6 +25,7 @@ describe('AssetProfilesService', () => { beforeEach(() => { deleteById = jest.fn(); emit = jest.fn(); + findMany = jest.fn(); finished = jest.fn().mockResolvedValue(undefined); gatherSymbol = jest.fn().mockResolvedValue([{ finished }]); getUserIdsBySymbolProfileId = jest.fn().mockResolvedValue([]); @@ -34,11 +39,25 @@ describe('AssetProfilesService', () => { } as unknown as AssetProfileSplitService, null, { gatherSymbol } as unknown as DataGatheringService, - null, + { + getDataProvider: jest.fn().mockReturnValue({ + getDataProviderInfo: jest.fn() + }) + } as unknown as DataProviderService, { emit } as unknown as EventEmitter2, null, - null, - null, + { + getLatest: jest.fn().mockResolvedValue(undefined) + } as unknown as MarketDataService, + { + marketData: { + count: jest.fn().mockResolvedValue(0) + }, + symbolProfile: { + findMany, + count: jest.fn().mockResolvedValue(0) + } + } as unknown as PrismaService, null ); }); @@ -126,6 +145,48 @@ describe('AssetProfilesService', () => { }); }); + describe('getAssetProfiles', () => { + it('flags the asset profiles used by users with a subscription', async () => { + findMany + .mockResolvedValueOnce([ + createSymbolProfile({ activitiesCount: 2, id: 'profile-id-1' }), + createSymbolProfile({ activitiesCount: 1, id: 'profile-id-2' }), + createSymbolProfile({ activitiesCount: 0, id: 'profile-id-3' }) + ]) + .mockResolvedValueOnce([{ id: 'profile-id-1' }]); + + const { assetProfiles } = await assetProfilesService.getAssetProfiles({}); + + expect(findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: { in: ['profile-id-1', 'profile-id-2'] } + }) + }) + ); + expect( + assetProfiles.map(({ id, isUsedByUsersWithSubscription }) => { + return { id, isUsedByUsersWithSubscription }; + }) + ).toEqual([ + { id: 'profile-id-1', isUsedByUsersWithSubscription: true }, + { id: 'profile-id-2', isUsedByUsersWithSubscription: false }, + { id: 'profile-id-3', isUsedByUsersWithSubscription: false } + ]); + }); + + it('skips the subscription query when no asset profile has activities', async () => { + findMany.mockResolvedValueOnce([ + createSymbolProfile({ activitiesCount: 0, id: 'profile-id-1' }) + ]); + + const { assetProfiles } = await assetProfilesService.getAssetProfiles({}); + + expect(findMany).toHaveBeenCalledTimes(1); + expect(assetProfiles[0].isUsedByUsersWithSubscription).toBe(false); + }); + }); + describe('deleteSplit', () => { it('throws NotFoundException when the scoped split does not exist', async () => { deleteById.mockResolvedValue(false); @@ -174,6 +235,23 @@ describe('AssetProfilesService', () => { }); }); +function createSymbolProfile({ + activitiesCount, + id +}: { + activitiesCount: number; + id: string; +}) { + return { + id, + _count: { activities: activitiesCount, watchedBy: 0 }, + activities: [], + assetProfileOverrides: null, + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }; +} + function flushPendingPromises() { return new Promise((resolve) => { setImmediate(resolve); diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index a104fd48cc..b0174e9258 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -287,10 +287,8 @@ export class AssetProfilesService { } } - const extendedPrismaClient = this.getExtendedPrismaClient(); - const symbolProfileResult = await Promise.all([ - extendedPrismaClient.symbolProfile.findMany({ + this.prismaService.symbolProfile.findMany({ skip, take, where, @@ -317,7 +315,6 @@ export class AssetProfilesService { id: true, isin: true, isActive: true, - isUsedByUsersWithSubscription: true, name: true, scraperConfiguration: true, sectors: true, @@ -329,6 +326,17 @@ export class AssetProfilesService { const symbolProfiles = symbolProfileResult[0]; let count = symbolProfileResult[1]; + const symbolProfileIdsUsedByUsersWithSubscription = + await this.getSymbolProfileIdsUsedByUsersWithSubscription( + symbolProfiles + .filter(({ _count }) => { + return _count.activities > 0; + }) + .map(({ id }) => { + return id; + }) + ); + const dataProviderInfoMap = new Map( [ ...new Set( @@ -357,7 +365,6 @@ export class AssetProfilesService { id, isin, isActive, - isUsedByUsersWithSubscription, symbol } = assetProfile; @@ -400,7 +407,8 @@ export class AssetProfilesService { activitiesCount: _count.activities, dataProviderInfo: dataProviderInfoMap.get(dataSource), date: activities?.[0]?.date, - isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, + isUsedByUsersWithSubscription: + symbolProfileIdsUsedByUsersWithSubscription.has(id), watchedByCount: _count.watchedBy }; }) @@ -562,47 +570,41 @@ export class AssetProfilesService { return { assetProfiles, count: assetProfiles.length }; } - private getExtendedPrismaClient() { - const symbolProfileExtension = Prisma.defineExtension((client) => { - return client.$extends({ - result: { - symbolProfile: { - isUsedByUsersWithSubscription: { - compute: async ({ id }) => { - const { _count } = - await this.prismaService.symbolProfile.findFirst({ - select: { - _count: { - select: { - activities: { - where: { - user: { - subscriptions: { - some: { - expiresAt: { - gt: new Date() - } - } - } - } - } - } - } - } - }, - where: { - id - } - }); - - return _count.activities > 0; + private async getSymbolProfileIdsUsedByUsersWithSubscription( + symbolProfileIds: string[] + ): Promise> { + if (symbolProfileIds.length === 0) { + return new Set(); + } + + const symbolProfiles = await this.prismaService.symbolProfile.findMany({ + select: { + id: true + }, + where: { + activities: { + some: { + user: { + subscriptions: { + some: { + expiresAt: { + gt: new Date() + } + } } } } + }, + id: { + in: symbolProfileIds } - }); + } }); - return this.prismaService.$extends(symbolProfileExtension); + return new Set( + symbolProfiles.map(({ id }) => { + return id; + }) + ); } }