diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index 2b4058ecb..4e935efb3 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -18,17 +18,17 @@ import { ActivitiesService } from './activities.service'; describe('ActivitiesService', () => { let activitiesService: ActivitiesService; - let getSplitsBySymbolProfileIds: jest.Mock; + let getSplitsByUserId: jest.Mock; let accountService: { getCashDetails: jest.Mock }; beforeEach(() => { - getSplitsBySymbolProfileIds = jest.fn(); + getSplitsByUserId = jest.fn().mockResolvedValue([]); accountService = { getCashDetails: jest.fn() }; activitiesService = new ActivitiesService( null, accountService as unknown as AccountService, - { getSplitsBySymbolProfileIds } as unknown as AssetProfileSplitService, + { getSplitsByUserId } as unknown as AssetProfileSplitService, null, null, null, @@ -137,12 +137,10 @@ describe('ActivitiesService', () => { activities: [activity], count: 1 }); - getSplitsBySymbolProfileIds.mockResolvedValue( - new Map([ - ['YAHOO-MSFT-profile', [split]], - ['MANUAL-AAPL-profile', [split]] - ]) - ); + getSplitsByUserId.mockResolvedValue([ + { ...split, symbolProfileId: 'YAHOO-MSFT-profile' }, + { ...split, symbolProfileId: 'MANUAL-AAPL-profile' } + ]); const result = await activitiesService.getActivitiesForPortfolioCalculator({ @@ -195,9 +193,7 @@ describe('ActivitiesService', () => { activities: [activity], count: 1 }); - getSplitsBySymbolProfileIds.mockResolvedValue( - new Map([['YAHOO-AAPL-profile', [split]]]) - ); + getSplitsByUserId.mockResolvedValue([split]); const result = await activitiesService.getActivitiesForPortfolioCalculator({ @@ -212,9 +208,7 @@ describe('ActivitiesService', () => { userId: 'user-id', withExcludedAccountsAndActivities: false }); - expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([ - 'YAHOO-AAPL-profile' - ]); + expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); expect(result.activities[0]).toMatchObject({ quantity: 20, unitPrice: 50, @@ -243,9 +237,7 @@ describe('ActivitiesService', () => { count: 1 }); accountService.getCashDetails.mockResolvedValue({ accounts: [] }); - getSplitsBySymbolProfileIds.mockResolvedValue( - new Map([['YAHOO-AAPL-profile', [split]]]) - ); + getSplitsByUserId.mockResolvedValue([split]); const result = await activitiesService.getActivitiesForPortfolioCalculator({ @@ -254,9 +246,7 @@ describe('ActivitiesService', () => { withCash: true }); - expect(getSplitsBySymbolProfileIds).toHaveBeenCalledWith([ - 'YAHOO-AAPL-profile' - ]); + expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); expect(result.activities).toEqual([ expect.objectContaining({ assetProfile: expect.objectContaining({ symbol: 'AAPL' }), @@ -274,8 +264,10 @@ describe('ActivitiesService', () => { activities: [activity], count: 1 }); - getSplitsBySymbolProfileIds.mockResolvedValue( - new Map([[activity.assetProfile.id, splits]]) + getSplitsByUserId.mockResolvedValue( + splits.map((split) => { + return { ...split, symbolProfileId: activity.assetProfile.id }; + }) ); const result = @@ -339,7 +331,7 @@ function createSplit( denominator, id: `${dateString}-${numerator}-${denominator}`, numerator, - symbolProfileId: 'aapl-profile', + symbolProfileId: 'YAHOO-AAPL-profile', updatedAt: date }; } diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 0f131d1fe..bfe35aeb0 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -60,7 +60,7 @@ import { } from '@prisma/client'; import { Big } from 'big.js'; import { endOfToday } from 'date-fns'; -import { groupBy, uniq, uniqBy } from 'lodash'; +import { groupBy, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @Injectable() @@ -918,34 +918,23 @@ export class ActivitiesService { /** Whether to include cash activities in the result. */ withCash?: boolean; }) { - const activities = await this.getActivities({ - filters, - userCurrency, - userId, - withExcludedAccountsAndActivities: false // TODO - }); - - const symbolProfileIds = uniq( - activities.activities.map(({ assetProfile }) => { - return assetProfile.id; - }) - ); - const splitsBySymbolProfileId = - await this.assetProfileSplitService.getSplitsBySymbolProfileIds( - symbolProfileIds - ); + const [activities, splits] = await Promise.all([ + this.getActivities({ + filters, + userCurrency, + userId, + withExcludedAccountsAndActivities: false // TODO + }), + this.assetProfileSplitService.getSplitsByUserId({ userId }) + ]); - const hasSplits = [...splitsBySymbolProfileId.values()].some((splits) => { - return splits.length > 0; - }); + if (splits.length > 0) { + const splitsBySymbolProfileId = groupBy(splits, 'symbolProfileId'); - if (hasSplits) { activities.activities = activities.activities.map((activity) => { - const key = activity.assetProfile.id; - return adjustActivityBySplits( activity, - splitsBySymbolProfileId.get(key) ?? [] + splitsBySymbolProfileId[activity.assetProfile.id] ?? [] ); }); } @@ -991,6 +980,29 @@ export class ActivitiesService { }; } + /** + * Returns the id of every user who has an activity for the given asset + * profile, including draft activities and activities of excluded accounts + */ + public async getUserIdsByAssetProfile({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise { + const activitiesByUser = await this.prismaService.order.groupBy({ + by: ['userId'], + where: { + SymbolProfile: { + dataSource, + symbol + } + } + }); + + return activitiesByUser.map(({ userId }) => { + return userId; + }); + } + public async order( orderWhereUniqueInput: Prisma.OrderWhereUniqueInput ): Promise { 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 ecfb28f67..14ee54229 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,3 +1,4 @@ +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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; @@ -12,19 +13,21 @@ 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 getUserIdsByAssetProfile: jest.Mock; let upsert: jest.Mock; beforeEach(() => { deleteById = jest.fn(); emit = jest.fn(); - findMany = jest.fn().mockResolvedValue([]); - gatherSymbol = jest.fn(); + finished = jest.fn().mockResolvedValue(undefined); + gatherSymbol = jest.fn().mockResolvedValue([{ finished }]); + getUserIdsByAssetProfile = jest.fn().mockResolvedValue([]); upsert = jest.fn(); assetProfilesService = new AssetProfilesService( - null, + { getUserIdsByAssetProfile } as unknown as ActivitiesService, { deleteById, upsert @@ -32,10 +35,10 @@ describe('AssetProfilesService', () => { null, { gatherSymbol } as unknown as DataGatheringService, null, - null, { emit } as unknown as EventEmitter2, null, - { order: { findMany } } as never, + null, + null, null ); }); @@ -69,25 +72,8 @@ describe('AssetProfilesService', () => { }); 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, - { emit } as unknown as EventEmitter2, - null, - { order: { findMany } } as never, - null - ); + upsert.mockResolvedValue({} as AssetProfileSplit); + getUserIdsByAssetProfile.mockResolvedValue(['user-1', 'user-2']); await assetProfilesService.createSplit({ dataSource: DataSource.YAHOO, @@ -97,11 +83,11 @@ describe('AssetProfilesService', () => { symbol: 'AAPL', symbolProfileId: 'profile-id' }); + await flushPendingPromises(); - expect(findMany).toHaveBeenCalledWith({ - distinct: ['userId'], - select: { userId: true }, - where: { symbolProfileId: 'profile-id' } + expect(getUserIdsByAssetProfile).toHaveBeenCalledWith({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL' }); expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ 'user-1', @@ -109,6 +95,37 @@ describe('AssetProfilesService', () => { ]); expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName()); }); + + it('emits the events only once the market data has been gathered', async () => { + let completeJob: () => void; + + finished.mockReturnValue( + new Promise((resolve) => { + completeJob = resolve; + }) + ); + upsert.mockResolvedValue({} as AssetProfileSplit); + getUserIdsByAssetProfile.mockResolvedValue(['user-1']); + + await assetProfilesService.createSplit({ + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }); + await flushPendingPromises(); + + expect(emit).not.toHaveBeenCalled(); + + completeJob(); + await flushPendingPromises(); + + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1' + ]); + }); }); describe('deleteSplit', () => { @@ -123,13 +140,15 @@ describe('AssetProfilesService', () => { symbolProfileId: 'profile-id' }) ).rejects.toBeInstanceOf(NotFoundException); + await flushPendingPromises(); + expect(gatherSymbol).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }); it('deletes an existing split using its profile scope', async () => { deleteById.mockResolvedValue(true); - findMany.mockResolvedValue([{ userId: 'user-1' }]); + getUserIdsByAssetProfile.mockResolvedValue(['user-1']); await expect( assetProfilesService.deleteSplit({ @@ -139,6 +158,7 @@ describe('AssetProfilesService', () => { symbolProfileId: 'profile-id' }) ).resolves.toBeUndefined(); + await flushPendingPromises(); expect(deleteById).toHaveBeenCalledWith({ id: 'split-id', @@ -154,3 +174,9 @@ describe('AssetProfilesService', () => { }); }); }); + +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 ebef26fb7..9f52f2b37 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 @@ -38,8 +38,8 @@ export class AssetProfilesService { private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, - private readonly exchangeRateDataService: ExchangeRateDataService, private readonly eventEmitter: EventEmitter2, + private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService @@ -65,8 +65,10 @@ export class AssetProfilesService { symbolProfileId }); - await this.emitPortfolioChangedEvents(symbolProfileId); - await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); + await this.gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol + }); return assetProfileSplit; } @@ -89,23 +91,10 @@ export class AssetProfilesService { throw new NotFoundException(); } - await this.emitPortfolioChangedEvents(symbolProfileId); - await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); - } - - private async emitPortfolioChangedEvents(symbolProfileId: string) { - const users = await this.prismaService.order.findMany({ - distinct: ['userId'], - select: { userId: true }, - where: { symbolProfileId } + await this.gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol }); - - for (const { userId } of users) { - this.eventEmitter.emit( - PortfolioChangedEvent.getName(), - new PortfolioChangedEvent({ userId }) - ); - } } public async getAssetProfile({ @@ -449,6 +438,47 @@ export class AssetProfilesService { return assetProfile; } + private async emitPortfolioChangedEvents({ + dataSource, + symbol + }: AssetProfileIdentifier) { + const userIds = await this.activitiesService.getUserIdsByAssetProfile({ + dataSource, + symbol + }); + + for (const userId of userIds) { + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ userId }) + ); + } + } + + /** + * Gathers the market data of the given asset profile and invalidates the + * portfolio snapshots of the affected users as soon as it is available. + * Emitting the events earlier would recompute the snapshots from + * split-adjusted quantities and not yet split-adjusted market prices. + */ + private async gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol + }: AssetProfileIdentifier) { + const jobs = await this.dataGatheringService.gatherSymbol({ + dataSource, + symbol + }); + + void Promise.allSettled( + jobs.map((job) => { + return job.finished(); + }) + ).then(() => { + return this.emitPortfolioChangedEvents({ dataSource, symbol }); + }); + } + private getAssetProfileDataUpdate({ countries, holdings, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts deleted file mode 100644 index 99f3106eb..000000000 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - activityDummyData, - assetProfileDummyData, - userDummyData -} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; -import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; -import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; -import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; -import { Activity } from '@ghostfolio/common/interfaces'; -import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; - -import { Big } from 'big.js'; - -import { PortfolioCalculatorFactory } from './portfolio-calculator.factory'; - -jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { - return { - CurrentRateService: jest.fn().mockImplementation(() => { - return CurrentRateServiceMock; - }) - }; -}); - -jest.mock( - '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', - () => { - return { - PortfolioSnapshotService: jest.fn().mockImplementation(() => { - return PortfolioSnapshotServiceMock; - }) - }; - } -); - -jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { - return { - RedisCacheService: jest.fn().mockImplementation(() => { - return RedisCacheServiceMock; - }) - }; -}); - -describe('PortfolioCalculatorFactory', () => { - let configurationService: ConfigurationService; - let currentRateService: CurrentRateService; - let exchangeRateDataService: ExchangeRateDataService; - let portfolioCalculatorFactory: PortfolioCalculatorFactory; - let portfolioSnapshotService: PortfolioSnapshotService; - let redisCacheService: RedisCacheService; - - beforeEach(() => { - PortfolioSnapshotServiceMock.reset(); - RedisCacheServiceMock.reset(); - - configurationService = new ConfigurationService(); - currentRateService = new CurrentRateService(null, null, null, null); - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - portfolioSnapshotService = new PortfolioSnapshotService(null, null); - redisCacheService = new RedisCacheService(null, null); - portfolioCalculatorFactory = new PortfolioCalculatorFactory( - configurationService, - currentRateService, - exchangeRateDataService, - portfolioSnapshotService, - redisCacheService - ); - }); - - it.each([ - PerformanceCalculationType.ROAI, - PerformanceCalculationType.MWR, - PerformanceCalculationType.TWR, - PerformanceCalculationType.ROI - ])( - 'passes adjusted activities to the shared calculator base for %s', - (calculationType) => { - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Apple Inc.', - symbol: 'AAPL' - }, - date: new Date('2020-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 20, - type: 'BUY', - unitPriceInAssetProfileCurrency: 50 - } - ]; - - const calculator = portfolioCalculatorFactory.createCalculator({ - activities, - calculationType, - currency: 'USD', - userId: userDummyData.id - }); - const position = calculator.getTransactionPoints()[0].items[0]; - - expect(position.quantity).toEqual(new Big(20)); - expect(position.averagePrice).toEqual(new Big(50)); - } - ); -}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index faf46de89..e50b132d2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -126,7 +126,7 @@ describe('PortfolioCalculator', () => { accountBalanceService, accountService, { - getSplitsBySymbolProfileIds: jest.fn().mockResolvedValue(new Map()) + getSplitsByUserId: jest.fn().mockResolvedValue([]) } as unknown as AssetProfileSplitService, null, null, diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts index 34154d441..dda77a5aa 100644 --- a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts @@ -15,26 +15,44 @@ export function adjustActivityBySplits( } const activityDate = resetHours(activity.date); - let splitFactor = new Big(1); + + // Accumulate both parts of the ratio and divide only once, so that the + // cumulative split factor of consecutive splits stays exact + let denominator = new Big(1); + let numerator = new Big(1); for (const split of splits) { + // Skip malformed splits to not break the portfolio calculation of every + // user holding this asset profile + if (split.denominator <= 0 || split.numerator <= 0) { + continue; + } + if (isBefore(activityDate, split.date)) { - splitFactor = splitFactor.mul(split.numerator).div(split.denominator); + denominator = denominator.mul(split.denominator); + numerator = numerator.mul(split.numerator); } } - if (splitFactor.eq(1)) { + if (numerator.eq(denominator)) { return activity; } return { ...activity, - quantity: new Big(activity.quantity).mul(splitFactor).toNumber(), - unitPrice: new Big(activity.unitPrice).div(splitFactor).toNumber(), + quantity: new Big(activity.quantity) + .mul(numerator) + .div(denominator) + .toNumber(), + unitPrice: new Big(activity.unitPrice) + .mul(denominator) + .div(numerator) + .toNumber(), unitPriceInAssetProfileCurrency: new Big( activity.unitPriceInAssetProfileCurrency ) - .div(splitFactor) + .mul(denominator) + .div(numerator) .toNumber() }; } diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts index dd520fa18..be5d7caa3 100644 --- a/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts @@ -56,38 +56,33 @@ describe('AssetProfileSplitService', () => { }); }); - describe('getSplitsBySymbolProfileIds', () => { - it('fetches and groups all splits with one ordered query', async () => { - const symbolProfileIds = ['aapl-profile', 'msft-profile']; - const aaplSplit = createSplit({ - date: new Date('2020-08-31'), - symbolProfileId: symbolProfileIds[0] - }); - const msftSplit = createSplit({ - date: new Date('2021-09-16'), - symbolProfileId: symbolProfileIds[1] - }); + describe('getSplitsByUserId', () => { + it('fetches the splits of the asset profiles held by the user with one ordered query', async () => { + const splits = [ + createStoredSplit('2020-08-31'), + createStoredSplit('2021-09-16') + ]; - findMany.mockResolvedValue([aaplSplit, msftSplit]); + findMany.mockResolvedValue(splits); - const splits = - await assetProfileSplitService.getSplitsBySymbolProfileIds( - symbolProfileIds - ); + const result = await assetProfileSplitService.getSplitsByUserId({ + userId: 'user-id' + }); + expect(result).toBe(splits); expect(findMany).toHaveBeenCalledTimes(1); expect(findMany).toHaveBeenCalledWith({ orderBy: [{ date: 'asc' }], where: { - symbolProfileId: { in: symbolProfileIds } + symbolProfile: { + activities: { + some: { + userId: 'user-id' + } + } + } } }); - expect(splits).toEqual( - new Map([ - [symbolProfileIds[0], [aaplSplit]], - [symbolProfileIds[1], [msftSplit]] - ]) - ); }); }); @@ -151,24 +146,6 @@ describe('AssetProfileSplitService', () => { }); }); -function createSplit({ - date, - symbolProfileId -}: { - date: Date; - symbolProfileId: string; -}): AssetProfileSplit { - return { - createdAt: date, - date, - denominator: 1, - id: `${symbolProfileId}-split`, - numerator: 2, - symbolProfileId, - updatedAt: date - }; -} - function createStoredSplit(date: string): AssetProfileSplit { const splitDate = new Date(date); diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts index c4012dc38..31dd7935c 100644 --- a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts @@ -53,34 +53,30 @@ export class AssetProfileSplitService { } /** - * Returns the splits of all given asset profiles, grouped by their - * data-source/symbol identifier. + * Returns the splits of all asset profiles the given user has activities + * for, in ascending order by date */ - public async getSplitsBySymbolProfileIds( - symbolProfileIds: string[] - ): Promise> { - const splitsByAssetProfile = new Map(); - - for (const symbolProfileId of symbolProfileIds) { - splitsByAssetProfile.set(symbolProfileId, []); - } - - if (symbolProfileIds.length === 0) { - return splitsByAssetProfile; - } - - const splits = await this.prismaService.assetProfileSplit.findMany({ - orderBy: [{ date: 'asc' }], + public async getSplitsByUserId({ + userId + }: { + userId: string; + }): Promise { + return this.prismaService.assetProfileSplit.findMany({ + orderBy: [ + { + date: 'asc' + } + ], where: { - symbolProfileId: { in: symbolProfileIds } + symbolProfile: { + activities: { + some: { + userId + } + } + } } }); - - for (const split of splits) { - splitsByAssetProfile.get(split.symbolProfileId)?.push(split); - } - - return splitsByAssetProfile; } public async upsert({ diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index d418c4f3f..17906a333 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -29,7 +29,7 @@ import { import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { JobOptions, Queue } from 'bull'; +import { Job, JobOptions, Queue } from 'bull'; import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; import { isEmpty } from 'lodash'; import ms, { StringValue } from 'ms'; @@ -285,7 +285,7 @@ export class DataGatheringService { date: date ?? item.date })); - await this.gatherSymbols({ + return this.gatherSymbols({ dataGatheringItems, force: true, priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH @@ -336,8 +336,8 @@ export class DataGatheringService { dataGatheringItems: DataGatheringItem[]; force?: boolean; priority: number; - }) { - await this.addJobsToQueue( + }): Promise { + return this.addJobsToQueue( dataGatheringItems.map(({ dataSource, date, symbol }) => { return { data: { diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 96533a9e7..6dab85dad 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -138,7 +138,9 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createAccountBalance, permissions.createActivity, - permissions.createAssetProfileSplitOfOwnAssetProfile, + // TODO: Grant createAssetProfileSplitOfOwnAssetProfile and + // deleteAssetProfileSplitOfOwnAssetProfile once the stock splits + // feature is no longer experimental permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, permissions.createWatchlistItem, @@ -146,7 +148,6 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteAccountBalance, permissions.deleteActivity, - permissions.deleteAssetProfileSplitOfOwnAssetProfile, permissions.deleteAuthDevice, permissions.deleteWatchlistItem, permissions.readAiPrompt,