From 87cb77a8fd814eba6eee811d1130040b182eddcc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:30:59 +0200 Subject: [PATCH] Feature/merge asset profile into existing asset profile (#7654) * Merge asset profile into existing asset profile * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/admin/admin.controller.ts | 16 ++ apps/api/src/app/admin/admin.module.ts | 2 + apps/api/src/app/admin/admin.service.ts | 229 +++++++++++++++++- .../asset-profiles.service.spec.ts | 2 + .../asset-profiles/asset-profiles.service.ts | 77 ++++-- .../services/benchmark/benchmark.service.ts | 60 +++-- .../data-gathering/data-gathering.service.ts | 9 +- .../admin-market-data.component.ts | 4 +- .../asset-profile-dialog.component.ts | 85 ++++++- libs/common/src/lib/dtos/index.ts | 2 + .../src/lib/dtos/merge-asset-profile.dto.ts | 10 + libs/common/src/lib/helper.ts | 25 +- libs/ui/src/lib/services/admin.service.ts | 11 + 14 files changed, 464 insertions(+), 69 deletions(-) create mode 100644 libs/common/src/lib/dtos/merge-asset-profile.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6899c6f..7a4c68ec7 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 ### Added - Added the write scopes to the access +- Added support to merge an asset profile into an existing asset profile in the asset profile dialog of the admin control panel (experimental) ### Changed diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 8653191b9..ade8781d6 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -15,6 +15,7 @@ import { GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS } from '@ghostfolio/common/config'; import { + MergeAssetProfileDto, UpdateAssetProfileDto, UpdatePropertyDto } from '@ghostfolio/common/dtos'; @@ -299,6 +300,21 @@ export class AdminController { return this.adminService.deleteProfileData({ dataSource, symbol }); } + @HasPermission(permissions.accessAdminControl) + @Post('profile-data/:dataSource/:symbol/merge') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async mergeAssetProfile( + @Body() targetAssetProfile: MergeAssetProfileDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + return this.adminService.mergeAssetProfile( + { dataSource, symbol }, + targetAssetProfile + ); + } + @HasPermission(permissions.accessAdminControl) @Patch('profile-data/:dataSource/:symbol') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 2d5c734dc..bb7679f60 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,3 +1,4 @@ +import { AssetProfilesModule } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; @@ -18,6 +19,7 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ + AssetProfilesModule, BenchmarkModule, ConfigurationModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index d384e0d55..ec90cfe24 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,4 +1,6 @@ +import { AssetProfilesService } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.service'; import { environment } from '@ghostfolio/api/environments/environment'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -14,6 +16,7 @@ import { } from '@ghostfolio/common/config'; import { applyAssetProfileOverrides, + canMergeAssetProfile, getAssetProfileIdentifier, getCurrencyFromSymbol, hasGhostfolioPrefix @@ -22,14 +25,18 @@ import { AdminData, AdminUserResponse, AdminUsersResponse, - AssetProfileIdentifier + AssetProfileIdentifier, + EnhancedAssetProfile } from '@ghostfolio/common/interfaces'; import { PropertyKey } from '@ghostfolio/common/types'; import { BadRequestException, + ConflictException, HttpException, Injectable, + InternalServerErrorException, + Logger, NotFoundException } from '@nestjs/common'; import { @@ -46,7 +53,11 @@ import { randomUUID } from 'node:crypto'; @Injectable() export class AdminService { + private readonly logger = new Logger(AdminService.name); + public constructor( + private readonly assetProfilesService: AssetProfilesService, + private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -233,6 +244,222 @@ export class AdminService { return { count, users }; } + /** + * Merges the source asset profile into the target asset profile. The + * activities, the watchlist entries and the market data which is missing + * there are moved to the target asset profile. The metadata of the source + * asset profile is discarded, because the target asset profile is the + * authoritative one. Then the source asset profile is deleted and the market + * data of the target asset profile is gathered again. + */ + public async mergeAssetProfile( + sourceAssetProfileIdentifier: AssetProfileIdentifier, + targetAssetProfileIdentifier: AssetProfileIdentifier + ): Promise { + if ( + getAssetProfileIdentifier(sourceAssetProfileIdentifier) === + getAssetProfileIdentifier(targetAssetProfileIdentifier) + ) { + throw new BadRequestException( + 'The source and the target asset profile must be different' + ); + } + + // The rules of the symbol apply to both asset profiles and are examined + // first. The remaining rules apply to the source asset profile only, + // because the merge deletes it + for (const assetProfileIdentifier of [ + sourceAssetProfileIdentifier, + targetAssetProfileIdentifier + ]) { + if (!canMergeAssetProfile(assetProfileIdentifier)) { + throw new BadRequestException( + `The asset profile ${getAssetProfileIdentifier(assetProfileIdentifier)} cannot be merged` + ); + } + } + + const [sourceAssetProfile, targetAssetProfile] = await Promise.all([ + this.prismaService.symbolProfile.findUnique({ + include: { watchedBy: { select: { id: true } } }, + where: { + dataSource_symbol: { + dataSource: sourceAssetProfileIdentifier.dataSource, + symbol: sourceAssetProfileIdentifier.symbol + } + } + }), + this.prismaService.symbolProfile.findUnique({ + where: { + dataSource_symbol: { + dataSource: targetAssetProfileIdentifier.dataSource, + symbol: targetAssetProfileIdentifier.symbol + } + } + }) + ]); + + if (!sourceAssetProfile || !targetAssetProfile) { + throw new NotFoundException( + 'The source or the target asset profile does not exist' + ); + } + + // An activity without a currency inherits the currency of its asset + // profile, hence a merge into an asset profile with another currency + // would change the value of the moved activities + if (sourceAssetProfile.currency !== targetAssetProfile.currency) { + throw new BadRequestException( + `The currency of the source asset profile (${sourceAssetProfile.currency}) does not match the currency of the target asset profile (${targetAssetProfile.currency})` + ); + } + + // A user asset profile must not become accessible to another user and a + // user must not become unable to be deleted, because the activities of the + // source asset profile would keep a reference to it + if (sourceAssetProfile.userId !== targetAssetProfile.userId) { + throw new BadRequestException( + 'The source and the target asset profile must belong to the same user' + ); + } + + const [isBenchmark, splitsCount] = await Promise.all([ + this.benchmarkService.isBenchmark(sourceAssetProfile.id), + this.prismaService.assetProfileSplit.count({ + where: { + symbolProfileId: { + in: [sourceAssetProfile.id, targetAssetProfile.id] + } + } + }) + ]); + + if ( + !canMergeAssetProfile({ + isBenchmark, + splitsCount, + symbol: sourceAssetProfileIdentifier.symbol + }) + ) { + throw new BadRequestException( + `The asset profile ${getAssetProfileIdentifier(sourceAssetProfileIdentifier)} cannot be merged. Remove the benchmark and the splits before the merge.` + ); + } + + const marketDataItems = await this.prismaService.marketData.findMany({ + select: { date: true, marketPrice: true, state: true }, + where: { + dataSource: sourceAssetProfileIdentifier.dataSource, + symbol: sourceAssetProfileIdentifier.symbol + } + }); + + const operations: Prisma.PrismaPromise[] = [ + this.prismaService.order.updateMany({ + data: { symbolProfileId: targetAssetProfile.id }, + where: { symbolProfileId: sourceAssetProfile.id } + }), + this.prismaService.symbolProfile.update({ + data: { + watchedBy: { + connect: sourceAssetProfile.watchedBy.map(({ id }) => { + return { id }; + }) + } + }, + where: { id: targetAssetProfile.id } + }), + this.prismaService.marketData.createMany({ + data: marketDataItems.map(({ date, marketPrice, state }) => { + return { + date, + marketPrice, + state, + dataSource: targetAssetProfileIdentifier.dataSource, + symbol: targetAssetProfileIdentifier.symbol + }; + }), + skipDuplicates: true + }), + // The market data has no relation to the asset profile and is therefore + // not deleted in cascade + this.prismaService.marketData.deleteMany({ + where: { + dataSource: sourceAssetProfileIdentifier.dataSource, + symbol: sourceAssetProfileIdentifier.symbol + } + }), + this.prismaService.symbolProfile.delete({ + where: { id: sourceAssetProfile.id } + }) + ]; + + const mergeDescription = `${getAssetProfileIdentifier( + sourceAssetProfileIdentifier + )} into ${getAssetProfileIdentifier(targetAssetProfileIdentifier)}`; + + try { + await this.prismaService.$transaction(operations); + } catch (error) { + this.logger.error( + `Could not merge the asset profile ${mergeDescription}`, + error.stack + ); + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new ConflictException( + `The asset profile ${mergeDescription} could not be merged, because it has been changed in the meantime` + ); + } + + throw new InternalServerErrorException( + `The asset profile ${mergeDescription} could not be merged` + ); + } + + // The merge is committed at this point. The gathering of the market data + // only updates derived data, thus an error must not fail the request. + // Otherwise the admin would retry a merge which has already been done. + try { + // The activities now refer to the market data of the target asset + // profile, hence the portfolio snapshots are already wrong and are + // invalidated immediately. The market data is not gathered with force, + // because a forced gathering deletes the market data of the days which + // the data provider does not return, including the days which have been + // copied from the source asset profile. + await this.assetProfilesService.gatherSymbolAndEmitPortfolioChangedEvents( + { + ...targetAssetProfileIdentifier, + force: false, + symbolProfileId: targetAssetProfile.id, + withImmediateInvalidation: true + } + ); + } catch (error) { + this.logger.error( + `The asset profile ${mergeDescription} has been merged, but the market data could not be gathered`, + error.stack + ); + } + + const [mergedAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ + targetAssetProfileIdentifier + ]); + + if (!mergedAssetProfile) { + this.logger.error( + `The asset profile ${mergeDescription} has been merged, but the merged asset profile could not be read` + ); + + throw new InternalServerErrorException( + `The asset profile ${mergeDescription} has been merged, but the merged asset profile could not be read. Reload the page.` + ); + } + + return mergedAssetProfile; + } + public async patchAssetProfileData( { dataSource, symbol }: AssetProfileIdentifier, { 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 909c96e8b..e6c483ebf 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 @@ -66,6 +66,7 @@ describe('AssetProfilesService', () => { }); expect(gatherSymbol).toHaveBeenCalledWith({ dataSource: data.dataSource, + force: true, symbol: data.symbol }); expect(result).toBe(split); @@ -166,6 +167,7 @@ describe('AssetProfilesService', () => { ]); expect(gatherSymbol).toHaveBeenCalledWith({ dataSource: DataSource.YAHOO, + force: true, symbol: 'AAPL' }); }); 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 e34feb649..c6ca5ebf4 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 @@ -25,13 +25,15 @@ import { } from '@ghostfolio/common/interfaces'; import { MarketDataPreset } from '@ghostfolio/common/types'; -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { groupBy } from 'lodash'; @Injectable() export class AssetProfilesService { + private readonly logger = new Logger(AssetProfilesService.name); + public constructor( private readonly activitiesService: ActivitiesService, private readonly assetProfileSplitService: AssetProfileSplitService, @@ -99,6 +101,54 @@ export class AssetProfilesService { }); } + /** + * 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. + * + * With withImmediateInvalidation the snapshots are invalidated a second + * time, before the gathering. This is necessary if the snapshots are already + * wrong without new market data, because the invalidation after the + * gathering is held in memory and is therefore lost if the process restarts. + */ + public async gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + force = true, + symbol, + symbolProfileId, + withImmediateInvalidation = false + }: { + force?: boolean; + symbolProfileId: string; + withImmediateInvalidation?: boolean; + } & AssetProfileIdentifier) { + if (withImmediateInvalidation) { + await this.emitPortfolioChangedEvents(symbolProfileId); + } + + const jobs = await this.dataGatheringService.gatherSymbol({ + dataSource, + force, + symbol + }); + + void Promise.allSettled( + jobs.map((job) => { + return job.finished(); + }) + ) + .then(() => { + return this.emitPortfolioChangedEvents(symbolProfileId); + }) + .catch((error) => { + this.logger.error( + `Could not emit the portfolio changed events of the asset profile ${getAssetProfileIdentifier({ dataSource, symbol })}`, + error.stack + ); + }); + } + public async getAssetProfile({ dataSource, symbol @@ -452,31 +502,6 @@ export class AssetProfilesService { } } - /** - * 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, - symbolProfileId - }: { symbolProfileId: string } & AssetProfileIdentifier) { - const jobs = await this.dataGatheringService.gatherSymbol({ - dataSource, - symbol - }); - - void Promise.allSettled( - jobs.map((job) => { - return job.finished(); - }) - ).then(() => { - return this.emitPortfolioChangedEvents(symbolProfileId); - }); - } - private getAssetProfileDataUpdate({ countries, holdings, diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 993e0f0aa..176a18f37 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -145,7 +145,7 @@ export class BenchmarkService { public async addBenchmark({ dataSource, symbol - }: AssetProfileIdentifier): Promise | undefined> { + }: AssetProfileIdentifier): Promise | null> { const assetProfile = await this.prismaService.symbolProfile.findFirst({ where: { dataSource, @@ -154,23 +154,14 @@ export class BenchmarkService { }); if (!assetProfile) { - return; + return null; } - let benchmarks = - (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS, - { skipCache: true } - )) ?? []; + const benchmarks = await this.getBenchmarksProperty(); benchmarks.push({ symbolProfileId: assetProfile.id }); - benchmarks = uniqBy(benchmarks, 'symbolProfileId'); - - await this.propertyService.put({ - key: PROPERTY_BENCHMARKS, - value: JSON.stringify(benchmarks) - }); + await this.putBenchmarksProperty(uniqBy(benchmarks, 'symbolProfileId')); return { dataSource, @@ -195,20 +186,13 @@ export class BenchmarkService { return null; } - let benchmarks = - (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS, - { skipCache: true } - )) ?? []; + const benchmarks = await this.getBenchmarksProperty(); - benchmarks = benchmarks.filter(({ symbolProfileId }) => { - return symbolProfileId !== assetProfile.id; - }); - - await this.propertyService.put({ - key: PROPERTY_BENCHMARKS, - value: JSON.stringify(benchmarks) - }); + await this.putBenchmarksProperty( + benchmarks.filter((benchmark) => { + return benchmark.symbolProfileId !== assetProfile.id; + }) + ); return { dataSource, @@ -232,6 +216,14 @@ export class BenchmarkService { } } + public async isBenchmark(symbolProfileId: string): Promise { + const benchmarks = await this.getBenchmarksProperty(); + + return benchmarks.some((benchmark) => { + return benchmark.symbolProfileId === symbolProfileId; + }); + } + private async calculateAndCacheBenchmarks({ enableSharing = false }): Promise { @@ -323,4 +315,20 @@ export class BenchmarkService { return benchmarks; } + + private async getBenchmarksProperty(): Promise { + return ( + (await this.propertyService.getByKey( + PROPERTY_BENCHMARKS, + { skipCache: true } + )) ?? [] + ); + } + + private async putBenchmarksProperty(benchmarks: BenchmarkProperty[]) { + await this.propertyService.put({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify(benchmarks) + }); + } } 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 17906a333..968acead5 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 @@ -272,7 +272,12 @@ export class DataGatheringService { }); } - public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) { + public async gatherSymbol({ + dataSource, + date, + force = true, + symbol + }: DataGatheringItem) { const dataGatheringItems = (await this.getSymbolsMax()) .filter((dataGatheringItem) => { return ( @@ -287,7 +292,7 @@ export class DataGatheringService { return this.gatherSymbols({ dataGatheringItems, - force: true, + force, priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH }); } diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 8592070ee..87389f957 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -450,11 +450,11 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((newAssetProfileIdentifier) => { + this.reloadData(); + if (newAssetProfileIdentifier) { this.onOpenAssetProfileDialog(newAssetProfileIdentifier); } else { - this.reloadData(); - this.router.navigate(['.'], { relativeTo: this.route }); } }); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 314fd21b5..3b9f942a7 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -8,6 +8,7 @@ import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { canDeleteAssetProfile, + canMergeAssetProfile, DATE_FORMAT, getCountryName, getCurrencyFromSymbol, @@ -797,13 +798,12 @@ export class GfAssetProfileDialogComponent implements OnInit { } this.patchAssetProfileIdentifier({ - getErrorMessage: (error) => { - if (error.status === StatusCodes.CONFLICT) { - // TODO: Ask if the user wants to merge the two asset profiles - - return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`; - } - + conflictFn: this.user?.settings?.isExperimentalFeatures + ? () => { + this.mergeAssetProfile(newAssetProfileIdentifier); + } + : undefined, + getErrorMessage: () => { return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`; }, title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?`, @@ -907,11 +907,72 @@ export class GfAssetProfileDialogComponent implements OnInit { return null; } + private mergeAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + if ( + !canMergeAssetProfile({ + isBenchmark: this.isBenchmark, + splitsCount: this.splits.length, + symbol: this.data.symbol + }) || + !canMergeAssetProfile({ symbol }) + ) { + this.notificationService.alert({ + message: $localize`This asset profile cannot be merged into ${symbol} (${dataSource}).`, + title: $localize`Error` + }); + + return; + } + + this.notificationService.confirm({ + confirmFn: () => { + this.adminService + .mergeAssetProfile( + { + dataSource: this.data.dataSource, + symbol: this.data.symbol + }, + { dataSource, symbol } + ) + .pipe( + catchError(({ error }: HttpErrorResponse) => { + this.notificationService.alert({ + message: + error?.message ?? + $localize`An error occurred while merging this asset profile into ${symbol} (${dataSource}).`, + title: $localize`Error` + }); + + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((mergedAssetProfile) => { + this.dialogRef.close({ + dataSource: mergedAssetProfile.dataSource, + symbol: mergedAssetProfile.symbol + }); + }); + }, + confirmType: ConfirmationDialogType.Warn, + message: + $localize`The data of this asset profile is moved to ${symbol} (${dataSource}) and this asset profile is deleted.` + + ' ' + + $localize`This action cannot be undone.`, + title: + $localize`${symbol} (${dataSource}) is already in use.` + + ' ' + + $localize`Do you really want to merge this asset profile into it?` + }); + } + private patchAssetProfileIdentifier({ + conflictFn, getErrorMessage, title, updateAssetProfileDto }: { + conflictFn?: () => void; getErrorMessage: (error: HttpErrorResponse) => string; title: string; updateAssetProfileDto: UpdateAssetProfileDto; @@ -929,9 +990,13 @@ export class GfAssetProfileDialogComponent implements OnInit { ) .pipe( catchError((error: HttpErrorResponse) => { - this.snackBar.open(getErrorMessage(error), undefined, { - duration: ms('3 seconds') - }); + if (error.status === StatusCodes.CONFLICT && conflictFn) { + conflictFn(); + } else { + this.snackBar.open(getErrorMessage(error), undefined, { + duration: ms('3 seconds') + }); + } return EMPTY; }), diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts index 7cf385cd7..a679af249 100644 --- a/libs/common/src/lib/dtos/index.ts +++ b/libs/common/src/lib/dtos/index.ts @@ -13,6 +13,7 @@ import { CreateTagDto } from './create-tag.dto'; import { CreateWatchlistItemDto } from './create-watchlist-item.dto'; import { DeleteOwnUserDto } from './delete-own-user.dto'; import { HoldingDto } from './holding.dto'; +import { MergeAssetProfileDto } from './merge-asset-profile.dto'; import { ScraperConfigurationDto } from './scraper-configuration.dto'; import { SectorDto } from './sector.dto'; import { TransferBalanceDto } from './transfer-balance.dto'; @@ -45,6 +46,7 @@ export { CreateWatchlistItemDto, DeleteOwnUserDto, HoldingDto, + MergeAssetProfileDto, ScraperConfigurationDto, SectorDto, TransferBalanceDto, diff --git a/libs/common/src/lib/dtos/merge-asset-profile.dto.ts b/libs/common/src/lib/dtos/merge-asset-profile.dto.ts new file mode 100644 index 000000000..f7b74bc4e --- /dev/null +++ b/libs/common/src/lib/dtos/merge-asset-profile.dto.ts @@ -0,0 +1,10 @@ +import { DataSource } from '@prisma/client'; +import { IsEnum, IsString } from 'class-validator'; + +export class MergeAssetProfileDto { + @IsEnum(DataSource) + dataSource: DataSource; + + @IsString() + symbol: string; +} diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index f7b79b247..69a7d358e 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -185,9 +185,8 @@ export function canDeleteAssetProfile({ activitiesCount === 0 && !isBenchmark && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) && + !isFearAndGreedIndexSymbol(symbol) && !isRootCurrency(getCurrencyFromSymbol(symbol)) && - symbol !== ghostfolioFearAndGreedIndexSymbolCryptocurrencies && - symbol !== ghostfolioFearAndGreedIndexSymbolStocks && watchedByCount === 0 ); } @@ -202,6 +201,21 @@ export function canDeleteUser({ return currentUserId !== userId; } +export function canMergeAssetProfile({ + isBenchmark = false, + splitsCount = 0, + symbol +}: Pick & { + splitsCount?: number; +}): boolean { + return ( + !isBenchmark && + !isCurrencySymbol(symbol) && + !isFearAndGreedIndexSymbol(symbol) && + splitsCount === 0 + ); +} + export function canOpenHoldingDetail({ assetProfile }: Pick): boolean { @@ -585,6 +599,13 @@ export function isDraftActivity(activity?: { tags?: { id: string }[] }) { ); } +export function isFearAndGreedIndexSymbol(aSymbol: string) { + return ( + aSymbol === ghostfolioFearAndGreedIndexSymbolCryptocurrencies || + aSymbol === ghostfolioFearAndGreedIndexSymbolStocks + ); +} + export function isRootCurrency(aCurrency: string) { if (aCurrency === 'USD') { return true; diff --git a/libs/ui/src/lib/services/admin.service.ts b/libs/ui/src/lib/services/admin.service.ts index 8510f1729..a1735f9de 100644 --- a/libs/ui/src/lib/services/admin.service.ts +++ b/libs/ui/src/lib/services/admin.service.ts @@ -6,6 +6,7 @@ import { import { CreateAssetProfileSplitDto, CreatePlatformDto, + MergeAssetProfileDto, UpdateAssetProfileDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; @@ -188,6 +189,16 @@ export class AdminService { return this.http.get(url); } + public mergeAssetProfile( + { dataSource, symbol }: AssetProfileIdentifier, + targetAssetProfile: MergeAssetProfileDto + ) { + return this.http.post( + `/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}/merge`, + targetAssetProfile + ); + } + public patchAssetProfile( { dataSource, symbol }: AssetProfileIdentifier, {