diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index da04ca46df..7d59ac9a1a 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -19,8 +19,7 @@ import { AdminData, AdminUserResponse, AdminUsersResponse, - AssetProfileIdentifier, - Holding + AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { PropertyKey } from '@ghostfolio/common/types'; @@ -40,6 +39,7 @@ import { } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { randomUUID } from 'node:crypto'; @Injectable() export class AdminService { @@ -241,16 +241,26 @@ export class AdminService { url }: Prisma.SymbolProfileUpdateInput ) { + const isConversionToManualDataSource = + newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL; + + if (isConversionToManualDataSource && !newSymbol) { + // The generated symbol avoids collisions in the MANUAL namespace + newSymbol = randomUUID(); + } + if ( newDataSource && newSymbol && (newDataSource !== dataSource || newSymbol !== symbol) ) { + const newAssetProfileIdentifier: AssetProfileIdentifier = { + dataSource: newDataSource as DataSource, + symbol: newSymbol as string + }; + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } + newAssetProfileIdentifier ]); if (assetProfile) { @@ -260,74 +270,86 @@ export class AdminService { ); } - try { - await Promise.all([ - this.symbolProfileService.updateAssetProfileIdentifier( - { - dataSource, - symbol - }, - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } - ), - this.marketDataService.updateAssetProfileIdentifier( + const operations: Prisma.PrismaPromise[] = [ + this.symbolProfileService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + newAssetProfileIdentifier + ), + this.marketDataService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + newAssetProfileIdentifier + ) + ]; + + if (isConversionToManualDataSource) { + const [currentAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ { dataSource, symbol - }, - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string } - ) - ]); - - if (DataSource[newDataSource.toString()] === DataSource.MANUAL) { - await Promise.all([ - this.symbolProfileService.deleteAssetProfileOverrides({ - dataSource: DataSource.MANUAL, - symbol: newSymbol as string - }), - this.symbolProfileService.updateSymbolProfile( - { - dataSource: DataSource.MANUAL, - symbol: newSymbol as string - }, - { - assetClass, - assetSubClass, - countries, - name, - sectors, - url, - holdings: (holdings as unknown as Holding[])?.map((holding) => { - return { - name: holding.name, - weight: holding.allocationInPercentage - }; - }) - } - ) ]); + + if (!currentAssetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); } - const [updatedAssetProfile] = - await this.symbolProfileService.getSymbolProfiles([ + operations.push( + // The overrides are not read for MANUAL asset profiles + this.symbolProfileService.deleteAssetProfileOverrides( + newAssetProfileIdentifier + ), + // Persist the current values, merged with the overrides + this.symbolProfileService.updateSymbolProfile( + newAssetProfileIdentifier, { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string + assetClass: currentAssetProfile.assetClass, + assetSubClass: currentAssetProfile.assetSubClass, + countries: currentAssetProfile.countries?.map( + ({ code, weight }) => { + return { code, weight }; + } + ), + holdings: currentAssetProfile.holdings?.map((holding) => { + return { + name: holding.name, + weight: holding.allocationInPercentage + }; + }), + name: currentAssetProfile.name, + sectors: currentAssetProfile.sectors?.map((sector) => { + return { name: sector.name, weight: sector.weight }; + }), + url: currentAssetProfile.url } - ]); + ) + ); + } - return updatedAssetProfile; + try { + await this.prismaService.$transaction(operations); } catch { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), StatusCodes.BAD_REQUEST ); } + + const [updatedAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ + newAssetProfileIdentifier + ]); + + return updatedAssetProfile; } else { const assetProfileOverrides = { assetClass: assetClass as AssetClass, diff --git a/apps/api/src/services/market-data/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts index 086434724b..ad388ce5c5 100644 --- a/apps/api/src/services/market-data/market-data.service.ts +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -204,7 +204,7 @@ export class MarketDataService { ); } - public async updateAssetProfileIdentifier( + public updateAssetProfileIdentifier( oldAssetProfileIdentifier: AssetProfileIdentifier, newAssetProfileIdentifier: AssetProfileIdentifier ) { diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 5c057338e8..3f40db247e 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -35,7 +35,7 @@ export class SymbolProfileService { }); } - public async deleteAssetProfileOverrides({ + public deleteAssetProfileOverrides({ 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 a3d3015b5d..7c4d367dab 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 @@ -473,62 +473,15 @@ export class GfAssetProfileDialogComponent implements OnInit { } protected onConvertToManualDataSource() { - this.notificationService.confirm({ - confirmFn: () => { - const newAssetProfileIdentifier: AssetProfileIdentifier = { - dataSource: DataSource.MANUAL, - symbol: crypto.randomUUID() - }; - - const convertedAssetProfile: UpdateAssetProfileDto = { - assetClass: this.assetProfile?.assetClass ?? undefined, - assetSubClass: this.assetProfile?.assetSubClass ?? undefined, - countries: this.assetProfile?.countries?.map(({ code, weight }) => { - return { code, weight }; - }), - dataSource: newAssetProfileIdentifier.dataSource, - holdings: this.assetProfile?.holdings?.map( - ({ allocationInPercentage, name }) => { - return { allocationInPercentage, name }; - } - ), - name: this.assetProfile?.name ?? undefined, - sectors: this.assetProfile?.sectors?.map(({ name, weight }) => { - return { name, weight }; - }), - symbol: newAssetProfileIdentifier.symbol, - url: this.assetProfile?.url ?? undefined - }; - - this.adminService - .patchAssetProfile( - { - dataSource: this.data.dataSource, - symbol: this.data.symbol - }, - convertedAssetProfile - ) - .pipe( - catchError(() => { - this.snackBar.open( - '😞 ' + - $localize`An error occurred while converting the asset profile to ${DataSource.MANUAL}.`, - undefined, - { - duration: ms('3 seconds') - } - ); - - return EMPTY; - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(() => { - this.dialogRef.close(newAssetProfileIdentifier); - }); + this.patchAssetProfileIdentifier({ + getErrorMessage: () => { + return ( + '😞 ' + + $localize`An error occurred while converting the asset profile to ${DataSource.MANUAL}.` + ); }, - confirmType: ConfirmationDialogType.Primary, - title: $localize`Do you really want to convert this asset profile to ${DataSource.MANUAL} data source?` + title: $localize`Do you really want to convert this asset profile to ${DataSource.MANUAL} data source?`, + updateAssetProfileDto: { dataSource: DataSource.MANUAL } }); } @@ -755,48 +708,18 @@ export class GfAssetProfileDialogComponent implements OnInit { return; } - this.notificationService.confirm({ - confirmFn: () => { - this.adminService - .patchAssetProfile( - { - dataSource: this.data.dataSource, - symbol: this.data.symbol - }, - assetProfileIdentifier - ) - .pipe( - catchError((error: HttpErrorResponse) => { - if (error.status === StatusCodes.CONFLICT) { - // TODO: Ask if the user wants to merge the two asset profiles - - this.snackBar.open( - $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`, - undefined, - { - duration: ms('3 seconds') - } - ); - } else { - this.snackBar.open( - $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`, - undefined, - { - duration: ms('3 seconds') - } - ); - } + this.patchAssetProfileIdentifier({ + getErrorMessage: (error) => { + if (error.status === StatusCodes.CONFLICT) { + // TODO: Ask if the user wants to merge the two asset profiles - return EMPTY; - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(() => { - this.dialogRef.close(newAssetProfileIdentifier); - }); + return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`; + } + + return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`; }, - confirmType: ConfirmationDialogType.Primary, - title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?` + title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?`, + updateAssetProfileDto: assetProfileIdentifier }); } @@ -895,4 +818,42 @@ export class GfAssetProfileDialogComponent implements OnInit { return null; } + + private patchAssetProfileIdentifier({ + getErrorMessage, + title, + updateAssetProfileDto + }: { + getErrorMessage: (error: HttpErrorResponse) => string; + title: string; + updateAssetProfileDto: UpdateAssetProfileDto; + }) { + this.notificationService.confirm({ + title, + confirmFn: () => { + this.adminService + .patchAssetProfile( + { + dataSource: this.data.dataSource, + symbol: this.data.symbol + }, + updateAssetProfileDto + ) + .pipe( + catchError((error: HttpErrorResponse) => { + this.snackBar.open(getErrorMessage(error), undefined, { + duration: ms('3 seconds') + }); + + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(({ dataSource, symbol }) => { + this.dialogRef.close({ dataSource, symbol }); + }); + }, + confirmType: ConfirmationDialogType.Primary + }); + } }