From 949606ddd042d50200c6930dc828f8ce7f74e863 Mon Sep 17 00:00:00 2001 From: David Requeno Date: Mon, 10 Aug 2026 21:38:49 -0600 Subject: [PATCH] Invalidate portfolio snapshots when stock splits change --- .../asset-profiles.service.spec.ts | 57 ++++++++++++++++++- .../asset-profiles/asset-profiles.service.ts | 21 +++++++ 2 files changed, 77 insertions(+), 1 deletion(-) 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 aa0241ae7..d4fba8e0d 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,7 +1,9 @@ +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetProfileSplit, DataSource } from '@prisma/client'; import { AssetProfilesService } from './asset-profiles.service'; @@ -9,11 +11,15 @@ import { AssetProfilesService } from './asset-profiles.service'; describe('AssetProfilesService', () => { let assetProfilesService: AssetProfilesService; let deleteById: jest.Mock; + let emit: jest.Mock; + let findMany: jest.Mock; let gatherSymbol: jest.Mock; let upsert: jest.Mock; beforeEach(() => { deleteById = jest.fn(); + emit = jest.fn(); + findMany = jest.fn().mockResolvedValue([]); gatherSymbol = jest.fn(); upsert = jest.fn(); @@ -28,8 +34,9 @@ describe('AssetProfilesService', () => { null, null, null, + { order: { findMany } } as never, null, - null + { emit } as unknown as EventEmitter2 ); }); @@ -60,6 +67,48 @@ describe('AssetProfilesService', () => { }); expect(result).toBe(split); }); + + it('invalidates portfolio snapshots for users holding the asset', async () => { + const split = {} as AssetProfileSplit; + upsert.mockResolvedValue(split); + findMany.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]); + + assetProfilesService = new AssetProfilesService( + null, + { + deleteById, + upsert + } as unknown as AssetProfileSplitService, + null, + { gatherSymbol } as unknown as DataGatheringService, + null, + null, + null, + { order: { findMany } } as never, + null, + { emit } as unknown as EventEmitter2 + ); + + await assetProfilesService.createSplit({ + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }); + + expect(findMany).toHaveBeenCalledWith({ + distinct: ['userId'], + select: { userId: true }, + where: { symbolProfileId: 'profile-id' } + }); + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1', + 'user-2' + ]); + expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName()); + }); }); describe('deleteSplit', () => { @@ -72,10 +121,13 @@ describe('AssetProfilesService', () => { symbolProfileId: 'profile-id' }) ).rejects.toBeInstanceOf(NotFoundException); + + expect(emit).not.toHaveBeenCalled(); }); it('deletes an existing split using its profile scope', async () => { deleteById.mockResolvedValue(true); + findMany.mockResolvedValue([{ userId: 'user-1' }]); await expect( assetProfilesService.deleteSplit({ @@ -88,6 +140,9 @@ describe('AssetProfilesService', () => { id: 'split-id', symbolProfileId: 'profile-id' }); + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1' + ]); }); }); }); 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 0d50e2223..fc86452dd 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 @@ -1,4 +1,5 @@ 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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; @@ -25,6 +26,7 @@ import { import { MarketDataPreset } from '@ghostfolio/common/types'; import { Injectable, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { groupBy } from 'lodash'; @@ -37,6 +39,7 @@ export class AssetProfilesService { private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly eventEmitter: EventEmitter2, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService @@ -62,6 +65,7 @@ export class AssetProfilesService { symbolProfileId }); + await this.emitPortfolioChangedEvents(symbolProfileId); await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); return assetProfileSplit; @@ -82,6 +86,23 @@ export class AssetProfilesService { if (!isDeleted) { throw new NotFoundException(); } + + await this.emitPortfolioChangedEvents(symbolProfileId); + } + + private async emitPortfolioChangedEvents(symbolProfileId: string) { + const users = await this.prismaService.order.findMany({ + distinct: ['userId'], + select: { userId: true }, + where: { symbolProfileId } + }); + + for (const { userId } of users) { + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ userId }) + ); + } } public async getAssetProfile({