Browse Source

Task/refactor subscription flag of asset profiles (#8264)

* Replace result extension with single query for subscription flag of asset profiles

* Update changelog
pull/8289/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
15fde30f9b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 84
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts
  3. 86
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

1
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

84
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);

86
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<DataSource, DataProviderInfo>(
[
...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<Set<string>> {
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;
})
);
}
}

Loading…
Cancel
Save