Browse Source

Merge asset profile into existing asset profile

pull/7654/head
Thomas Kaul 16 hours ago
parent
commit
c80bab47d0
  1. 16
      apps/api/src/app/admin/admin.controller.ts
  2. 208
      apps/api/src/app/admin/admin.service.ts
  3. 62
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  4. 2
      libs/common/src/lib/dtos/index.ts
  5. 10
      libs/common/src/lib/dtos/merge-asset-profile.dto.ts
  6. 11
      libs/ui/src/lib/services/admin.service.ts

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)

208
apps/api/src/app/admin/admin.service.ts

@ -1,12 +1,15 @@
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';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix, ghostfolioPrefix,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -16,13 +19,15 @@ import {
applyAssetProfileOverrides, applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol, getCurrencyFromSymbol,
hasGhostfolioPrefix hasGhostfolioPrefix,
isCurrencySymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { 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';
@ -47,7 +52,9 @@ import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@ -233,6 +240,203 @@ export class AdminService {
return { count, users }; 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<EnhancedAssetProfile> {
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<unknown>[] = [
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( public async patchAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier, { dataSource, symbol }: AssetProfileIdentifier,
{ {

62
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({ this.patchAssetProfileIdentifier({
getErrorMessage: (error) => { conflictFn: () => {
if (error.status === StatusCodes.CONFLICT) { this.mergeAssetProfile(newAssetProfileIdentifier);
// TODO: Ask if the user wants to merge the two asset profiles },
getErrorMessage: () => {
return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}
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 +904,52 @@ export class GfAssetProfileDialogComponent implements OnInit {
return null; 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({ 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 +967,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
) )
.pipe( .pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
this.snackBar.open(getErrorMessage(error), undefined, { if (error.status === StatusCodes.CONFLICT && conflictFn) {
duration: ms('3 seconds') conflictFn();
}); } else {
this.snackBar.open(getErrorMessage(error), undefined, {
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;
}

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