Browse Source

Support migrating asset profile to MANUAL data source

pull/7376/head
Thomas Kaul 1 month ago
parent
commit
031cf6009d
  1. 138
      apps/api/src/app/admin/admin.service.ts
  2. 2
      apps/api/src/services/market-data/market-data.service.ts
  3. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  4. 151
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

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

@ -19,8 +19,7 @@ import {
AdminData, AdminData,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
AssetProfileIdentifier, AssetProfileIdentifier
Holding
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PropertyKey } from '@ghostfolio/common/types'; import { PropertyKey } from '@ghostfolio/common/types';
@ -40,6 +39,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@ -241,16 +241,26 @@ export class AdminService {
url url
}: Prisma.SymbolProfileUpdateInput }: 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 ( if (
newDataSource && newDataSource &&
newSymbol && newSymbol &&
(newDataSource !== dataSource || newSymbol !== symbol) (newDataSource !== dataSource || newSymbol !== symbol)
) { ) {
const newAssetProfileIdentifier: AssetProfileIdentifier = {
dataSource: newDataSource as DataSource,
symbol: newSymbol as string
};
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ newAssetProfileIdentifier
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]); ]);
if (assetProfile) { if (assetProfile) {
@ -260,74 +270,86 @@ export class AdminService {
); );
} }
try { const operations: Prisma.PrismaPromise<unknown>[] = [
await Promise.all([ this.symbolProfileService.updateAssetProfileIdentifier(
this.symbolProfileService.updateAssetProfileIdentifier( {
{ dataSource,
dataSource, symbol
symbol },
}, newAssetProfileIdentifier
{ ),
dataSource: DataSource[newDataSource.toString()], this.marketDataService.updateAssetProfileIdentifier(
symbol: newSymbol as string {
} dataSource,
), symbol
this.marketDataService.updateAssetProfileIdentifier( },
newAssetProfileIdentifier
)
];
if (isConversionToManualDataSource) {
const [currentAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
{ {
dataSource, dataSource,
symbol 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] = operations.push(
await this.symbolProfileService.getSymbolProfiles([ // 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()], assetClass: currentAssetProfile.assetClass,
symbol: newSymbol as string 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 { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]);
return updatedAssetProfile;
} else { } else {
const assetProfileOverrides = { const assetProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,

2
apps/api/src/services/market-data/market-data.service.ts

@ -204,7 +204,7 @@ export class MarketDataService {
); );
} }
public async updateAssetProfileIdentifier( public updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier, oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier newAssetProfileIdentifier: AssetProfileIdentifier
) { ) {

2
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -35,7 +35,7 @@ export class SymbolProfileService {
}); });
} }
public async deleteAssetProfileOverrides({ public deleteAssetProfileOverrides({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {

151
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() { protected onConvertToManualDataSource() {
this.notificationService.confirm({ this.patchAssetProfileIdentifier({
confirmFn: () => { getErrorMessage: () => {
const newAssetProfileIdentifier: AssetProfileIdentifier = { return (
dataSource: DataSource.MANUAL, '😞 ' +
symbol: crypto.randomUUID() $localize`An error occurred while converting the asset profile to ${DataSource.MANUAL}.`
}; );
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);
});
}, },
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; return;
} }
this.notificationService.confirm({ this.patchAssetProfileIdentifier({
confirmFn: () => { getErrorMessage: (error) => {
this.adminService if (error.status === StatusCodes.CONFLICT) {
.patchAssetProfile( // TODO: Ask if the user wants to merge the two asset profiles
{
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')
}
);
}
return EMPTY; return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}), }
takeUntilDestroyed(this.destroyRef)
) return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`;
.subscribe(() => {
this.dialogRef.close(newAssetProfileIdentifier);
});
}, },
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; 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
});
}
} }

Loading…
Cancel
Save