diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4a3bc17..2ca1c9291 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 for converting an asset profile to the `MANUAL` data source in the asset profile details dialog of the admin control panel + ### Changed - Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 8325fa90e..26a4e06f4 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -12,6 +12,7 @@ import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; import { + applyAssetProfileOverrides, getAssetProfileIdentifier, getCurrencyFromSymbol } from '@ghostfolio/common/helper'; @@ -39,6 +40,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 { @@ -240,16 +242,25 @@ export class AdminService { url }: Prisma.SymbolProfileUpdateInput ) { + const isConversionToManualDataSource = + newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL; + + if (isConversionToManualDataSource && !newSymbol) { + newSymbol = randomUUID(); + } + if ( - newSymbol && newDataSource && - (newSymbol !== symbol || newDataSource !== dataSource) + 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) { @@ -259,45 +270,79 @@ export class AdminService { ); } - try { - await Promise.all([ - this.symbolProfileService.updateAssetProfileIdentifier( - { - dataSource, - symbol - }, - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } + const operations: Prisma.PrismaPromise[] = [ + this.symbolProfileService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + newAssetProfileIdentifier + ), + this.marketDataService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + newAssetProfileIdentifier + ) + ]; + + if (isConversionToManualDataSource) { + const currentAssetProfile = + await this.prismaService.symbolProfile.findUnique({ + include: { assetProfileOverrides: true }, + where: { dataSource_symbol: { dataSource, symbol } } + }); + + if (!currentAssetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const currentAssetProfileWithOverrides = applyAssetProfileOverrides( + currentAssetProfile, + currentAssetProfile.assetProfileOverrides + ); + + operations.push( + // The overrides are applied on every read, so delete them and + // persist the merged values in the asset profile instead + this.symbolProfileService.deleteAssetProfileOverrides( + newAssetProfileIdentifier ), - this.marketDataService.updateAssetProfileIdentifier( + this.symbolProfileService.updateSymbolProfile( + newAssetProfileIdentifier, { - dataSource, - symbol - }, - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string + assetClass: currentAssetProfileWithOverrides.assetClass, + assetSubClass: currentAssetProfileWithOverrides.assetSubClass, + countries: + currentAssetProfileWithOverrides.countries ?? undefined, + holdings: currentAssetProfileWithOverrides.holdings ?? undefined, + name: currentAssetProfileWithOverrides.name, + sectors: currentAssetProfileWithOverrides.sectors ?? undefined, + url: currentAssetProfileWithOverrides.url } ) - ]); - - const [updatedAssetProfile] = - await this.symbolProfileService.getSymbolProfiles([ - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } - ]); + ); + } - 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 086434724..ad388ce5c 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 7157f0856..3f40db247 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -35,6 +35,15 @@ export class SymbolProfileService { }); } + public deleteAssetProfileOverrides({ + dataSource, + symbol + }: AssetProfileIdentifier) { + return this.prismaService.assetProfileOverrides.deleteMany({ + where: { symbolProfile: { dataSource, symbol } } + }); + } + public async deleteById(id: string) { return this.prismaService.symbolProfile.delete({ where: { id } 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 e716907ef..b29ccc7d7 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 @@ -408,7 +408,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { const dialogRef = this.dialog.open< GfAssetProfileDialogComponent, - AssetProfileDialogParams + AssetProfileDialogParams, + AssetProfileIdentifier >(GfAssetProfileDialogComponent, { autoFocus: false, data: { @@ -426,15 +427,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { dialogRef .afterClosed() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe( - (newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => { - if (newAssetProfileIdentifier) { - this.onOpenAssetProfileDialog(newAssetProfileIdentifier); - } else { - this.router.navigate(['.'], { relativeTo: this.route }); - } + .subscribe((newAssetProfileIdentifier) => { + if (newAssetProfileIdentifier) { + this.onOpenAssetProfileDialog(newAssetProfileIdentifier); + } else { + 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 c34e8eb78..c5562740f 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 @@ -5,6 +5,7 @@ import { PROPERTY_IS_DATA_GATHERING_ENABLED } from '@ghostfolio/common/config'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { canDeleteAssetProfile, DATE_FORMAT, @@ -75,6 +76,7 @@ import { AssetClass, AssetSubClass, DataGatheringFrequency, + DataSource, MarketData, Prisma, SymbolProfile @@ -215,6 +217,8 @@ export class GfAssetProfileDialogComponent implements OnInit { } ]; + protected readonly DataSource = DataSource; + protected readonly dateRangeOptions = [ { label: $localize`Current week` + ' (' + $localize`WTD` + ')', @@ -277,7 +281,10 @@ export class GfAssetProfileDialogComponent implements OnInit { @Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams, private dataService: DataService, private destroyRef: DestroyRef, - private dialogRef: MatDialogRef, + private dialogRef: MatDialogRef< + GfAssetProfileDialogComponent, + AssetProfileIdentifier + >, private formBuilder: FormBuilder, private notificationService: NotificationService, private snackBar: MatSnackBar, @@ -467,6 +474,19 @@ export class GfAssetProfileDialogComponent implements OnInit { this.dialogRef.close(); } + protected onConvertToManualDataSource() { + this.patchAssetProfileIdentifier({ + getErrorMessage: () => { + return ( + '😞 ' + + $localize`An error occurred while converting the data source to ${DataSource.MANUAL}.` + ); + }, + title: $localize`Do you really want to convert the data source to ${DataSource.MANUAL}?`, + updateAssetProfileDto: { dataSource: DataSource.MANUAL } + }); + } + protected onDeleteProfileData({ dataSource, symbol @@ -666,13 +686,16 @@ export class GfAssetProfileDialogComponent implements OnInit { } protected async onSubmitAssetProfileIdentifierForm() { + const newAssetProfileIdentifier = + this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value; + + if (!newAssetProfileIdentifier?.dataSource) { + return; + } + const assetProfileIdentifier: UpdateAssetProfileDto = { - dataSource: - this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value - ?.dataSource ?? undefined, - symbol: - this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value - ?.symbol ?? undefined + dataSource: newAssetProfileIdentifier.dataSource, + symbol: newAssetProfileIdentifier.symbol }; try { @@ -687,46 +710,19 @@ export class GfAssetProfileDialogComponent implements OnInit { return; } - this.adminService - .patchAssetProfile( - { - dataSource: this.data.dataSource, - symbol: this.data.symbol - }, - assetProfileIdentifier - ) - .pipe( - catchError((error: HttpErrorResponse) => { - if (error.status === StatusCodes.CONFLICT) { - 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(() => { - const newAssetProfileIdentifier = { - dataSource: assetProfileIdentifier.dataSource, - symbol: assetProfileIdentifier.symbol - }; + return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`; + } - this.dialogRef.close(newAssetProfileIdentifier); - }); + 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})?`, + updateAssetProfileDto: assetProfileIdentifier + }); } protected onTestMarketData() { @@ -824,4 +820,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 + }); + } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 9b69ef6fc..c9abdeeb7 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -162,6 +162,19 @@ Cancel + @if (data.dataSource !== DataSource.MANUAL) { +

or

+ + } } @else {