From 30e561c06f0e0712098daa6bc736a79a544e31ee Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 16 Oct 2023 21:54:36 +0200 Subject: [PATCH] Feature/extend assistant with search for asset profile (#2499) * Extend assistant with search for asset profile * Extend search results by currency, symbol and asset sub class * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/admin/admin.controller.ts | 21 ++--- apps/api/src/app/admin/admin.module.ts | 2 + apps/api/src/app/admin/admin.service.ts | 24 +++++- .../src/app/portfolio/portfolio.service.ts | 1 + apps/api/src/services/api/api.service.ts | 9 ++ .../components/header/header.component.html | 3 + .../interfaces/admin-market-data.interface.ts | 2 + .../src/lib/interfaces/position.interface.ts | 6 +- .../assistant-list-item.component.ts | 32 ++++++- .../assistant-list-item.html | 20 +++-- .../assistant-list-item.module.ts | 3 +- .../src/lib/assistant/assistant.component.ts | 83 +++++++++++++++++-- libs/ui/src/lib/assistant/assistant.html | 26 +++++- .../lib/assistant/interfaces/interfaces.ts | 11 ++- 15 files changed, 201 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5357fb8..02922b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances +- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental) ### Changed diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 2d602222..30270d0c 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,9 +1,9 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { - DEFAULT_PAGE_SIZE, GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_OPTIONS } from '@ghostfolio/common/config'; @@ -12,8 +12,7 @@ import { AdminData, AdminMarketData, AdminMarketDataDetails, - EnhancedSymbolProfile, - Filter + EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { @@ -50,6 +49,7 @@ import { UpdateMarketDataDto } from './update-market-data.dto'; export class AdminController { public constructor( private readonly adminService: AdminService, + private readonly apiService: ApiService, private readonly dataGatheringService: DataGatheringService, private readonly marketDataService: MarketDataService, @Inject(REQUEST) private readonly request: RequestWithUser @@ -255,6 +255,7 @@ export class AdminController { public async getMarketData( @Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('presetId') presetId?: MarketDataPreset, + @Query('query') filterBySearchQuery?: string, @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, @@ -272,16 +273,10 @@ export class AdminController { ); } - const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; - - const filters: Filter[] = [ - ...assetSubClasses.map((assetSubClass) => { - return { - id: assetSubClass, - type: 'ASSET_SUB_CLASS' - }; - }) - ]; + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAssetSubClasses, + filterBySearchQuery + }); return this.adminService.getMarketData({ filters, diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 500af69d..079af87f 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,4 +1,5 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; @@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ + ApiModule, ConfigurationModule, DataGatheringModule, DataProviderModule, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index dd9e3f9c..173854ea 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -131,10 +131,14 @@ export class AdminService { filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; } + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( filters, - (filter) => { - return filter.type; + ({ type }) => { + return type; } ); @@ -147,6 +151,14 @@ export class AdminService { where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; } + if (searchQuery) { + where.OR = [ + { isin: { mode: 'insensitive', startsWith: searchQuery } }, + { name: { mode: 'insensitive', startsWith: searchQuery } }, + { symbol: { mode: 'insensitive', startsWith: searchQuery } } + ]; + } + if (sortColumn) { orderBy = [{ [sortColumn]: sortDirection }]; @@ -173,7 +185,9 @@ export class AdminService { assetSubClass: true, comment: true, countries: true, + currency: true, dataSource: true, + name: true, Order: { orderBy: [{ date: 'asc' }], select: { date: true }, @@ -194,7 +208,9 @@ export class AdminService { assetSubClass, comment, countries, + currency, dataSource, + name, Order, sectors, symbol @@ -213,8 +229,10 @@ export class AdminService { assetClass, assetSubClass, comment, + currency, countriesCount, dataSource, + name, symbol, marketDataItemCount, sectorsCount, @@ -341,6 +359,8 @@ export class AdminService { symbol, assetClass: 'CASH', countriesCount: 0, + currency: symbol.replace(DEFAULT_CURRENCY, ''), + name: symbol, sectorsCount: 0 }; }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index de366908..fcd2cb13 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1088,6 +1088,7 @@ export class PortfolioService { return { ...position, assetClass: symbolProfileMap[position.symbol].assetClass, + assetSubClass: symbolProfileMap[position.symbol].assetSubClass, averagePrice: new Big(position.averagePrice).toNumber(), grossPerformance: position.grossPerformance?.toNumber() ?? null, grossPerformancePercentage: diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index 204aa030..8ef0df7b 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -8,16 +8,19 @@ export class ApiService { public buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterByAssetSubClasses, filterBySearchQuery, filterByTags }: { filterByAccounts?: string; filterByAssetClasses?: string; + filterByAssetSubClasses?: string; filterBySearchQuery?: string; filterByTags?: string; }): Filter[] { const accountIds = filterByAccounts?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? []; + const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const searchQuery = filterBySearchQuery?.toLowerCase(); const tagIds = filterByTags?.split(',') ?? []; @@ -34,6 +37,12 @@ export class ApiService { type: 'ASSET_CLASS' }; }), + ...assetSubClasses.map((assetClass) => { + return { + id: assetClass, + type: 'ASSET_SUB_CLASS' + }; + }), { id: searchQuery, type: 'SEARCH_QUERY' diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 45986df9..4d606f59 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -131,6 +131,9 @@ diff --git a/libs/common/src/lib/interfaces/admin-market-data.interface.ts b/libs/common/src/lib/interfaces/admin-market-data.interface.ts index d53562a2..08838d4b 100644 --- a/libs/common/src/lib/interfaces/admin-market-data.interface.ts +++ b/libs/common/src/lib/interfaces/admin-market-data.interface.ts @@ -9,9 +9,11 @@ export interface AdminMarketDataItem { assetClass?: AssetClass; assetSubClass?: AssetSubClass; countriesCount: number; + currency: string; dataSource: DataSource; date?: Date; marketDataItemCount: number; + name: string; sectorsCount: number; symbol: string; } diff --git a/libs/common/src/lib/interfaces/position.interface.ts b/libs/common/src/lib/interfaces/position.interface.ts index 6d94e344..1df07e0c 100644 --- a/libs/common/src/lib/interfaces/position.interface.ts +++ b/libs/common/src/lib/interfaces/position.interface.ts @@ -1,9 +1,9 @@ -import { AssetClass, DataSource } from '@prisma/client'; - -import { MarketState } from '../types'; +import { MarketState } from '@ghostfolio/common/types'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; export interface Position { assetClass: AssetClass; + assetSubClass: AssetSubClass; averagePrice: number; currency: string; dataSource: DataSource; diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts index 53a72206..d00977c1 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts @@ -7,10 +7,12 @@ import { EventEmitter, HostBinding, Input, + OnChanges, Output, ViewChild } from '@angular/core'; -import { Position } from '@ghostfolio/common/interfaces'; +import { Params } from '@angular/router'; +import { ISearchResultItem } from '@ghostfolio/ui/assistant/interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -18,22 +20,46 @@ import { Position } from '@ghostfolio/common/interfaces'; templateUrl: './assistant-list-item.html', styleUrls: ['./assistant-list-item.scss'] }) -export class AssistantListItemComponent implements FocusableOption { +export class AssistantListItemComponent implements FocusableOption, OnChanges { @HostBinding('attr.tabindex') tabindex = -1; @HostBinding('class.has-focus') get getHasFocus() { return this.hasFocus; } - @Input() holding: Position; + @Input() item: ISearchResultItem; + @Input() mode: 'assetProfile' | 'holding'; @Output() clicked = new EventEmitter(); @ViewChild('link') public linkElement: ElementRef; public hasFocus = false; + public queryParams: Params; + public routerLink: string[]; public constructor(private changeDetectorRef: ChangeDetectorRef) {} + public ngOnChanges() { + const dataSource = this.item?.dataSource; + const symbol = this.item?.symbol; + + if (this.mode === 'assetProfile') { + this.queryParams = { + dataSource, + symbol, + assetProfileDialog: true + }; + this.routerLink = ['/admin', 'market-data']; + } else if (this.mode === 'holding') { + this.queryParams = { + dataSource, + symbol, + positionDetailDialog: true + }; + this.routerLink = ['/portfolio', 'holdings']; + } + } + public focus() { this.hasFocus = true; diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html index 5e078241..d75db3c8 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html @@ -1,12 +1,16 @@ {{ holding?.name }}{{ item?.name }} +
+ {{ item?.symbol | gfSymbol }} · {{ item?.currency }} + · {{ item?.assetSubClassString }} diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts index 9a88fc91..0c2e8972 100644 --- a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts @@ -1,12 +1,13 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { AssistantListItemComponent } from './assistant-list-item.component'; @NgModule({ declarations: [AssistantListItemComponent], exports: [AssistantListItemComponent], - imports: [CommonModule, RouterModule] + imports: [CommonModule, GfSymbolModule, RouterModule] }) export class GfAssistantListItemModule {} diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index 30db8f2e..2cfd9eff 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -16,9 +16,10 @@ import { } from '@angular/core'; import { FormControl } from '@angular/forms'; import { MatMenuTrigger } from '@angular/material/menu'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; -import { Position } from '@ghostfolio/common/interfaces'; -import { EMPTY, Subject, lastValueFrom } from 'rxjs'; +import { translate } from '@ghostfolio/ui/i18n'; +import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { catchError, debounceTime, @@ -29,13 +30,13 @@ import { } from 'rxjs/operators'; import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; -import { ISearchResults } from './interfaces/interfaces'; +import { ISearchResultItem, ISearchResults } from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-assistant', - templateUrl: './assistant.html', - styleUrls: ['./assistant.scss'] + styleUrls: ['./assistant.scss'], + templateUrl: './assistant.html' }) export class AssistantComponent implements OnDestroy, OnInit { @HostListener('document:keydown', ['$event']) onKeydown( @@ -71,6 +72,7 @@ export class AssistantComponent implements OnDestroy, OnInit { } @Input() deviceType: string; + @Input() hasPermissionToAccessAdminControl: boolean; @Output() closed = new EventEmitter(); @@ -87,6 +89,7 @@ export class AssistantComponent implements OnDestroy, OnInit { public placeholder = $localize`Find holding...`; public searchFormControl = new FormControl(''); public searchResults: ISearchResults = { + assetProfiles: [], holdings: [] }; @@ -94,6 +97,7 @@ export class AssistantComponent implements OnDestroy, OnInit { private unsubscribeSubject = new Subject(); public constructor( + private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService ) {} @@ -104,6 +108,7 @@ export class AssistantComponent implements OnDestroy, OnInit { map((searchTerm) => { this.isLoading = true; this.searchResults = { + assetProfiles: [], holdings: [] }; @@ -115,6 +120,7 @@ export class AssistantComponent implements OnDestroy, OnInit { distinctUntilChanged(), mergeMap(async (searchTerm) => { const result = { + assetProfiles: [], holdings: [] }; @@ -140,6 +146,7 @@ export class AssistantComponent implements OnDestroy, OnInit { this.isLoading = true; this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.searchResults = { + assetProfiles: [], holdings: [] }; @@ -180,10 +187,23 @@ export class AssistantComponent implements OnDestroy, OnInit { } private async getSearchResults(aSearchTerm: string) { - let holdings: Position[] = []; + let assetProfiles: ISearchResultItem[] = []; + let holdings: ISearchResultItem[] = []; + + if (this.hasPermissionToAccessAdminControl) { + try { + assetProfiles = await lastValueFrom( + this.searchAssetProfiles(aSearchTerm) + ); + assetProfiles = assetProfiles.slice( + 0, + AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + ); + } catch {} + } try { - holdings = await lastValueFrom(this.searchHolding(aSearchTerm)); + holdings = await lastValueFrom(this.searchHoldings(aSearchTerm)); holdings = holdings.slice( 0, AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT @@ -191,11 +211,46 @@ export class AssistantComponent implements OnDestroy, OnInit { } catch {} return { + assetProfiles, holdings }; } - private searchHolding(aSearchTerm: string) { + private searchAssetProfiles( + aSearchTerm: string + ): Observable { + return this.adminService + .fetchAdminMarketData({ + filters: [ + { + id: aSearchTerm, + type: 'SEARCH_QUERY' + } + ], + take: AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + }) + .pipe( + catchError(() => { + return EMPTY; + }), + map(({ marketData }) => { + return marketData.map( + ({ assetSubClass, currency, dataSource, name, symbol }) => { + return { + currency, + dataSource, + name, + symbol, + assetSubClassString: translate(assetSubClass) + }; + } + ); + }), + takeUntil(this.unsubscribeSubject) + ); + } + + private searchHoldings(aSearchTerm: string): Observable { return this.dataService .fetchPositions({ filters: [ @@ -211,7 +266,17 @@ export class AssistantComponent implements OnDestroy, OnInit { return EMPTY; }), map(({ positions }) => { - return positions; + return positions.map( + ({ assetSubClass, currency, dataSource, name, symbol }) => { + return { + currency, + dataSource, + name, + symbol, + assetSubClassString: translate(assetSubClass) + }; + } + ); }), takeUntil(this.unsubscribeSubject) ); diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html index c5db2965..0644c945 100644 --- a/libs/ui/src/lib/assistant/assistant.html +++ b/libs/ui/src/lib/assistant/assistant.html @@ -45,8 +45,9 @@
Holdings
@@ -62,5 +63,26 @@
No entries...
+
+
Asset Profiles
+ + + +
No entries...
+
+
diff --git a/libs/ui/src/lib/assistant/interfaces/interfaces.ts b/libs/ui/src/lib/assistant/interfaces/interfaces.ts index 922091fb..99f70dbe 100644 --- a/libs/ui/src/lib/assistant/interfaces/interfaces.ts +++ b/libs/ui/src/lib/assistant/interfaces/interfaces.ts @@ -1,5 +1,12 @@ -import { Position } from '@ghostfolio/common/interfaces'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; + +export interface ISearchResultItem extends UniqueAsset { + assetSubClassString: string; + currency: string; + name: string; +} export interface ISearchResults { - holdings: Position[]; + assetProfiles: ISearchResultItem[]; + holdings: ISearchResultItem[]; }