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,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier,
Holding
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { PropertyKey } from '@ghostfolio/common/types';
@ -40,6 +39,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 {
@ -241,16 +241,26 @@ export class AdminService {
url
}: 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 (
newDataSource &&
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) {
@ -260,74 +270,86 @@ export class AdminService {
);
}
try {
await Promise.all([
this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
),
this.marketDataService.updateAssetProfileIdentifier(
const operations: Prisma.PrismaPromise<unknown>[] = [
this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
newAssetProfileIdentifier
),
this.marketDataService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
newAssetProfileIdentifier
)
];
if (isConversionToManualDataSource) {
const [currentAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
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] =
await this.symbolProfileService.getSymbolProfiles([
operations.push(
// 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()],
symbol: newSymbol as string
assetClass: currentAssetProfile.assetClass,
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 {
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,

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,
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,
symbol
}: 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() {
this.notificationService.confirm({
confirmFn: () => {
const newAssetProfileIdentifier: AssetProfileIdentifier = {
dataSource: DataSource.MANUAL,
symbol: crypto.randomUUID()
};
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);
});
this.patchAssetProfileIdentifier({
getErrorMessage: () => {
return (
'😞 ' +
$localize`An error occurred while converting the asset profile to ${DataSource.MANUAL}.`
);
},
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;
}
this.notificationService.confirm({
confirmFn: () => {
this.adminService
.patchAssetProfile(
{
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')
}
);
}
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(() => {
this.dialogRef.close(newAssetProfileIdentifier);
});
return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}
return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`;
},
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;
}
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