diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 7b647ba73..738589ba2 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -229,7 +229,7 @@ export class ActivitiesController { let assetProfiles: { [assetProfileIdentifier: string]: Partial; - } = {}; + }; try { assetProfiles = await this.dataProviderService.validateActivities({ @@ -382,8 +382,7 @@ export class ActivitiesController { }, update: { assetClass: data.assetClass, - assetSubClass: data.assetSubClass, - name: data.symbol + assetSubClass: data.assetSubClass } }, tags: data.tags?.map((id) => { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 3627de912..5b9ca13bf 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -896,7 +896,6 @@ export class ActivitiesService { data.type === 'BUY') ) { delete data.SymbolProfile.connect; - delete data.SymbolProfile.update.name; } else { delete data.SymbolProfile.update; diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts index 270acbcff..994222ef1 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts @@ -155,6 +155,27 @@ describe('DataGatheringService', () => { }) ); }); + + it('Creates a new asset profile with the symbol of the data provider', async () => { + dataProviderService.getAssetProfiles.mockResolvedValue({ + 'YAHOO-aapl': { + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + } + }); + + await dataGatheringService.gatherAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'aapl' } + ]); + + expect(prismaService.symbolProfile.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ symbol: 'AAPL' }) + }) + ); + }); }); describe('gatherRecentMarketData', () => { diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index fc7efbdec..53472fb34 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -166,8 +166,8 @@ export class DataGatheringService { isin, name, sectors, - symbol, - url + url, + symbol: assetProfile.symbol }, update: { assetClass, diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 970802b5a..715ec0831 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -143,14 +143,29 @@ export class SymbolProfileService { return symbol; } - const symbolProfile = await this.prismaService.symbolProfile.findFirst({ - where: { - dataSource, - symbol: { equals: symbol, mode: 'insensitive' } - } + const symbolProfile = await this.prismaService.symbolProfile.findUnique({ + where: { dataSource_symbol: { dataSource, symbol } } }); - return symbolProfile?.symbol ?? symbolOfDataProvider ?? symbol; + if (symbolProfile) { + return symbolProfile.symbol; + } + + const symbolProfileWithOtherLetterCase = + await this.prismaService.symbolProfile.findFirst({ + orderBy: { symbol: 'asc' }, + where: { + dataSource, + symbol: { + equals: this.escapeLikePattern(symbol), + mode: 'insensitive' + } + } + }); + + return ( + symbolProfileWithOtherLetterCase?.symbol ?? symbolOfDataProvider ?? symbol + ); } public async getSymbolProfiles( @@ -315,6 +330,14 @@ export class SymbolProfileService { }); } + /** + * Escapes the wildcard characters of a LIKE pattern, because Prisma + * translates a case-insensitive filter into an ILIKE expression. + */ + private escapeLikePattern(value: string) { + return value.replace(/[\\%_]/g, '\\$&'); + } + private getCountries(aCountries: Prisma.JsonArray = []): Country[] { if (aCountries === null) { return []; diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index ef70590ba..edcac079d 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -22,6 +22,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value'; import { SelectionModel } from '@angular/cdk/collections'; import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; import { AfterViewInit, ChangeDetectionStrategy, @@ -497,7 +498,21 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .addAssetProfile({ dataSource, symbol }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - error: () => { + error: (error: HttpErrorResponse) => { + const { message } = (error.error ?? {}) as { + message?: string; + }; + + this.snackBar.open( + '😞 ' + + (message ?? + $localize`An error occurred while creating the asset profile ${symbol} (${dataSource}).`), + undefined, + { + duration: ms('3 seconds') + } + ); + this.router.navigate(['.'], { relativeTo: this.route }); }, next: (assetProfile) => {