diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e49a4967..a9d25d336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +### Added + +- Added support to merge an asset profile into an existing asset profile in the asset profile dialog of the admin control panel + ### Changed - Upgraded `ng-extract-i18n-merge` from `3.3.0` to `3.4.0` 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.service.ts b/apps/api/src/app/admin/admin.service.ts index d384e0d55..b9275ed2a 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,12 +1,15 @@ 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'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, ghostfolioPrefix, PROPERTY_CURRENCIES, PROPERTY_IS_READ_ONLY_MODE, @@ -16,13 +19,15 @@ import { applyAssetProfileOverrides, getAssetProfileIdentifier, getCurrencyFromSymbol, - hasGhostfolioPrefix + hasGhostfolioPrefix, + isCurrencySymbol } from '@ghostfolio/common/helper'; import { AdminData, AdminUserResponse, AdminUsersResponse, - AssetProfileIdentifier + AssetProfileIdentifier, + EnhancedAssetProfile } from '@ghostfolio/common/interfaces'; import { PropertyKey } from '@ghostfolio/common/types'; @@ -47,7 +52,9 @@ import { randomUUID } from 'node:crypto'; @Injectable() export class AdminService { public constructor( + private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, @@ -233,6 +240,203 @@ export class AdminService { return { count, users }; } + /** + * Merges the source asset profile into the target asset profile. The + * activities and the watchlist entries are moved to the target asset profile + * and the market data and the splits which are missing there are copied. + * 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' + ); + } + + if ( + isCurrencySymbol(sourceAssetProfileIdentifier.symbol) || + isCurrencySymbol(targetAssetProfileIdentifier.symbol) + ) { + throw new BadRequestException( + 'The asset profile of a currency 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({ + include: { watchedBy: { select: { id: true } } }, + 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})` + ); + } + + const [marketDataItems, splits] = await Promise.all([ + this.prismaService.marketData.findMany({ + select: { date: true, marketPrice: true, state: true }, + where: { + dataSource: sourceAssetProfileIdentifier.dataSource, + symbol: sourceAssetProfileIdentifier.symbol + } + }), + this.prismaService.assetProfileSplit.findMany({ + select: { date: true, denominator: true, numerator: true }, + where: { symbolProfileId: sourceAssetProfile.id } + }) + ]); + + const userIdsWatchingTargetAssetProfile = new Set( + targetAssetProfile.watchedBy.map(({ id }) => { + return id; + }) + ); + + const usersToConnect = sourceAssetProfile.watchedBy.filter(({ id }) => { + return !userIdsWatchingTargetAssetProfile.has(id); + }); + + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + const isSourceAssetProfileBenchmark = benchmarkAssetProfiles.some( + ({ id }) => { + return id === sourceAssetProfile.id; + } + ); + + if (isSourceAssetProfileBenchmark) { + // A benchmark refers to the id of its asset profile, which cannot be + // resolved anymore after the source asset profile is deleted + await this.benchmarkService.addBenchmark(targetAssetProfileIdentifier); + await this.benchmarkService.deleteBenchmark(sourceAssetProfileIdentifier); + } + + const operations: Prisma.PrismaPromise[] = [ + this.prismaService.order.updateMany({ + data: { symbolProfileId: targetAssetProfile.id }, + where: { symbolProfileId: sourceAssetProfile.id } + }), + this.prismaService.symbolProfile.update({ + data: { + watchedBy: { + connect: usersToConnect.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 splits are copied as well, because they adjust the activities + // which are moved to the target asset profile + this.prismaService.assetProfileSplit.createMany({ + data: splits.map(({ date, denominator, numerator }) => { + return { + date, + denominator, + numerator, + symbolProfileId: targetAssetProfile.id + }; + }), + 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 } + }) + ]; + + try { + await this.prismaService.$transaction(operations); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + // The moved activities can start before the first market data item of the + // target asset profile. The market data is not gathered with force, + // because that replaces the market data which has just been copied. + const earliestActivity = await this.prismaService.order.findFirst({ + orderBy: { date: 'asc' }, + select: { date: true }, + where: { symbolProfileId: targetAssetProfile.id } + }); + + if (earliestActivity) { + await this.dataGatheringService.gatherSymbols({ + dataGatheringItems: [ + { + ...targetAssetProfileIdentifier, + date: earliestActivity.date + } + ], + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + + const [mergedAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ + targetAssetProfileIdentifier + ]); + + return mergedAssetProfile; + } + public async patchAssetProfileData( { dataSource, symbol }: AssetProfileIdentifier, { 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..53c39f582 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 @@ -797,13 +797,10 @@ 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.mergeAssetProfile(newAssetProfileIdentifier); + }, + 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 +904,52 @@ export class GfAssetProfileDialogComponent implements OnInit { return null; } + private mergeAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + this.notificationService.confirm({ + confirmFn: () => { + this.adminService + .mergeAssetProfile( + { + dataSource: this.data.dataSource, + symbol: this.data.symbol + }, + { dataSource, symbol } + ) + .pipe( + catchError(() => { + this.snackBar.open( + '😞 ' + + $localize`An error occurred while merging this asset profile into ${symbol} (${dataSource}).`, + undefined, + { + duration: ms('3 seconds') + } + ); + + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((mergedAssetProfile) => { + this.dialogRef.close({ + dataSource: mergedAssetProfile.dataSource, + symbol: mergedAssetProfile.symbol + }); + }); + }, + confirmType: ConfirmationDialogType.Warn, + message: $localize`The activities and the missing historical market data of this asset profile are moved to ${symbol} (${dataSource}). Then this asset profile is deleted. This action cannot be undone.`, + title: $localize`${symbol} (${dataSource}) is already in use. 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 +967,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/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, {