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