Browse Source

Feature/merge asset profile into existing asset profile (#7654)

* Merge asset profile into existing asset profile

* Update changelog
pull/7626/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
87cb77a8fd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 16
      apps/api/src/app/admin/admin.controller.ts
  3. 2
      apps/api/src/app/admin/admin.module.ts
  4. 229
      apps/api/src/app/admin/admin.service.ts
  5. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts
  6. 77
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  7. 60
      apps/api/src/services/benchmark/benchmark.service.ts
  8. 9
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  9. 4
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  10. 77
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  11. 2
      libs/common/src/lib/dtos/index.ts
  12. 10
      libs/common/src/lib/dtos/merge-asset-profile.dto.ts
  13. 25
      libs/common/src/lib/helper.ts
  14. 11
      libs/ui/src/lib/services/admin.service.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the write scopes to the access - 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 ### Changed

16
apps/api/src/app/admin/admin.controller.ts

@ -15,6 +15,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
MergeAssetProfileDto,
UpdateAssetProfileDto, UpdateAssetProfileDto,
UpdatePropertyDto UpdatePropertyDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
@ -299,6 +300,21 @@ export class AdminController {
return this.adminService.deleteProfileData({ dataSource, symbol }); 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<EnhancedAssetProfile> {
return this.adminService.mergeAssetProfile(
{ dataSource, symbol },
targetAssetProfile
);
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Patch('profile-data/:dataSource/:symbol') @Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

2
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 { 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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -18,6 +19,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
AssetProfilesModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringQueueModule, DataGatheringQueueModule,

229
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 { 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -14,6 +16,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
applyAssetProfileOverrides, applyAssetProfileOverrides,
canMergeAssetProfile,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol, getCurrencyFromSymbol,
hasGhostfolioPrefix hasGhostfolioPrefix
@ -22,14 +25,18 @@ import {
AdminData, AdminData,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
AssetProfileIdentifier AssetProfileIdentifier,
EnhancedAssetProfile
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PropertyKey } from '@ghostfolio/common/types'; import { PropertyKey } from '@ghostfolio/common/types';
import { import {
BadRequestException, BadRequestException,
ConflictException,
HttpException, HttpException,
Injectable, Injectable,
InternalServerErrorException,
Logger,
NotFoundException NotFoundException
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@ -46,7 +53,11 @@ import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
private readonly logger = new Logger(AdminService.name);
public constructor( public constructor(
private readonly assetProfilesService: AssetProfilesService,
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -233,6 +244,222 @@ export class AdminService {
return { count, users }; 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<EnhancedAssetProfile> {
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<unknown>[] = [
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( public async patchAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier, { dataSource, symbol }: AssetProfileIdentifier,
{ {

2
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts

@ -66,6 +66,7 @@ describe('AssetProfilesService', () => {
}); });
expect(gatherSymbol).toHaveBeenCalledWith({ expect(gatherSymbol).toHaveBeenCalledWith({
dataSource: data.dataSource, dataSource: data.dataSource,
force: true,
symbol: data.symbol symbol: data.symbol
}); });
expect(result).toBe(split); expect(result).toBe(split);
@ -166,6 +167,7 @@ describe('AssetProfilesService', () => {
]); ]);
expect(gatherSymbol).toHaveBeenCalledWith({ expect(gatherSymbol).toHaveBeenCalledWith({
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
force: true,
symbol: 'AAPL' symbol: 'AAPL'
}); });
}); });

77
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -25,13 +25,15 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; 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 { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AssetProfilesService { export class AssetProfilesService {
private readonly logger = new Logger(AssetProfilesService.name);
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService, 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({ public async getAssetProfile({
dataSource, dataSource,
symbol 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({ private getAssetProfileDataUpdate({
countries, countries,
holdings, holdings,

60
apps/api/src/services/benchmark/benchmark.service.ts

@ -145,7 +145,7 @@ export class BenchmarkService {
public async addBenchmark({ public async addBenchmark({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | undefined> { }: AssetProfileIdentifier): Promise<Partial<SymbolProfile> | null> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({ const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: { where: {
dataSource, dataSource,
@ -154,23 +154,14 @@ export class BenchmarkService {
}); });
if (!assetProfile) { if (!assetProfile) {
return; return null;
} }
let benchmarks = const benchmarks = await this.getBenchmarksProperty();
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id }); benchmarks.push({ symbolProfileId: assetProfile.id });
benchmarks = uniqBy(benchmarks, 'symbolProfileId'); await this.putBenchmarksProperty(uniqBy(benchmarks, 'symbolProfileId'));
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
return { return {
dataSource, dataSource,
@ -195,20 +186,13 @@ export class BenchmarkService {
return null; return null;
} }
let benchmarks = const benchmarks = await this.getBenchmarksProperty();
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
return symbolProfileId !== assetProfile.id;
});
await this.propertyService.put({ await this.putBenchmarksProperty(
key: PROPERTY_BENCHMARKS, benchmarks.filter((benchmark) => {
value: JSON.stringify(benchmarks) return benchmark.symbolProfileId !== assetProfile.id;
}); })
);
return { return {
dataSource, dataSource,
@ -232,6 +216,14 @@ export class BenchmarkService {
} }
} }
public async isBenchmark(symbolProfileId: string): Promise<boolean> {
const benchmarks = await this.getBenchmarksProperty();
return benchmarks.some((benchmark) => {
return benchmark.symbolProfileId === symbolProfileId;
});
}
private async calculateAndCacheBenchmarks({ private async calculateAndCacheBenchmarks({
enableSharing = false enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> { }): Promise<BenchmarkResponse['benchmarks']> {
@ -323,4 +315,20 @@ export class BenchmarkService {
return benchmarks; return benchmarks;
} }
private async getBenchmarksProperty(): Promise<BenchmarkProperty[]> {
return (
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []
);
}
private async putBenchmarksProperty(benchmarks: BenchmarkProperty[]) {
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
}
} }

9
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()) const dataGatheringItems = (await this.getSymbolsMax())
.filter((dataGatheringItem) => { .filter((dataGatheringItem) => {
return ( return (
@ -287,7 +292,7 @@ export class DataGatheringService {
return this.gatherSymbols({ return this.gatherSymbols({
dataGatheringItems, dataGatheringItems,
force: true, force,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
}); });
} }

4
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -450,11 +450,11 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((newAssetProfileIdentifier) => { .subscribe((newAssetProfileIdentifier) => {
this.reloadData();
if (newAssetProfileIdentifier) { if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier); this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else { } else {
this.reloadData();
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
}); });

77
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 { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
canMergeAssetProfile,
DATE_FORMAT, DATE_FORMAT,
getCountryName, getCountryName,
getCurrencyFromSymbol, getCurrencyFromSymbol,
@ -797,13 +798,12 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
this.patchAssetProfileIdentifier({ this.patchAssetProfileIdentifier({
getErrorMessage: (error) => { conflictFn: this.user?.settings?.isExperimentalFeatures
if (error.status === StatusCodes.CONFLICT) { ? () => {
// TODO: Ask if the user wants to merge the two asset profiles this.mergeAssetProfile(newAssetProfileIdentifier);
return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
} }
: undefined,
getErrorMessage: () => {
return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`; 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})?`, 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; 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({ private patchAssetProfileIdentifier({
conflictFn,
getErrorMessage, getErrorMessage,
title, title,
updateAssetProfileDto updateAssetProfileDto
}: { }: {
conflictFn?: () => void;
getErrorMessage: (error: HttpErrorResponse) => string; getErrorMessage: (error: HttpErrorResponse) => string;
title: string; title: string;
updateAssetProfileDto: UpdateAssetProfileDto; updateAssetProfileDto: UpdateAssetProfileDto;
@ -929,9 +990,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
) )
.pipe( .pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.CONFLICT && conflictFn) {
conflictFn();
} else {
this.snackBar.open(getErrorMessage(error), undefined, { this.snackBar.open(getErrorMessage(error), undefined, {
duration: ms('3 seconds') duration: ms('3 seconds')
}); });
}
return EMPTY; return EMPTY;
}), }),

2
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 { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { DeleteOwnUserDto } from './delete-own-user.dto'; import { DeleteOwnUserDto } from './delete-own-user.dto';
import { HoldingDto } from './holding.dto'; import { HoldingDto } from './holding.dto';
import { MergeAssetProfileDto } from './merge-asset-profile.dto';
import { ScraperConfigurationDto } from './scraper-configuration.dto'; import { ScraperConfigurationDto } from './scraper-configuration.dto';
import { SectorDto } from './sector.dto'; import { SectorDto } from './sector.dto';
import { TransferBalanceDto } from './transfer-balance.dto'; import { TransferBalanceDto } from './transfer-balance.dto';
@ -45,6 +46,7 @@ export {
CreateWatchlistItemDto, CreateWatchlistItemDto,
DeleteOwnUserDto, DeleteOwnUserDto,
HoldingDto, HoldingDto,
MergeAssetProfileDto,
ScraperConfigurationDto, ScraperConfigurationDto,
SectorDto, SectorDto,
TransferBalanceDto, TransferBalanceDto,

10
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;
}

25
libs/common/src/lib/helper.ts

@ -185,9 +185,8 @@ export function canDeleteAssetProfile({
activitiesCount === 0 && activitiesCount === 0 &&
!isBenchmark && !isBenchmark &&
!isDerivedCurrency(getCurrencyFromSymbol(symbol)) && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!isFearAndGreedIndexSymbol(symbol) &&
!isRootCurrency(getCurrencyFromSymbol(symbol)) && !isRootCurrency(getCurrencyFromSymbol(symbol)) &&
symbol !== ghostfolioFearAndGreedIndexSymbolCryptocurrencies &&
symbol !== ghostfolioFearAndGreedIndexSymbolStocks &&
watchedByCount === 0 watchedByCount === 0
); );
} }
@ -202,6 +201,21 @@ export function canDeleteUser({
return currentUserId !== userId; return currentUserId !== userId;
} }
export function canMergeAssetProfile({
isBenchmark = false,
splitsCount = 0,
symbol
}: Pick<AssetProfileItem, 'isBenchmark' | 'symbol'> & {
splitsCount?: number;
}): boolean {
return (
!isBenchmark &&
!isCurrencySymbol(symbol) &&
!isFearAndGreedIndexSymbol(symbol) &&
splitsCount === 0
);
}
export function canOpenHoldingDetail({ export function canOpenHoldingDetail({
assetProfile assetProfile
}: Pick<PortfolioPosition, 'assetProfile'>): boolean { }: Pick<PortfolioPosition, 'assetProfile'>): 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) { export function isRootCurrency(aCurrency: string) {
if (aCurrency === 'USD') { if (aCurrency === 'USD') {
return true; return true;

11
libs/ui/src/lib/services/admin.service.ts

@ -6,6 +6,7 @@ import {
import { import {
CreateAssetProfileSplitDto, CreateAssetProfileSplitDto,
CreatePlatformDto, CreatePlatformDto,
MergeAssetProfileDto,
UpdateAssetProfileDto, UpdateAssetProfileDto,
UpdatePlatformDto UpdatePlatformDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
@ -188,6 +189,16 @@ export class AdminService {
return this.http.get<DataProviderHistoricalResponse>(url); return this.http.get<DataProviderHistoricalResponse>(url);
} }
public mergeAssetProfile(
{ dataSource, symbol }: AssetProfileIdentifier,
targetAssetProfile: MergeAssetProfileDto
) {
return this.http.post<EnhancedAssetProfile>(
`/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}/merge`,
targetAssetProfile
);
}
public patchAssetProfile( public patchAssetProfile(
{ dataSource, symbol }: AssetProfileIdentifier, { dataSource, symbol }: AssetProfileIdentifier,
{ {

Loading…
Cancel
Save