diff --git a/CHANGELOG.md b/CHANGELOG.md index 92cd4366b..7c2e2e7cf 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 an icon to the external links in the footer navigation +- Added the ability to add an asset profile in the admin control panel ### Changed diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 3898a8876..6512384ba 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,3 +1,4 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; 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'; @@ -26,11 +27,12 @@ import { Post, Put, Query, - UseGuards + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { DataSource, MarketData } from '@prisma/client'; +import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { isDate } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -328,6 +330,28 @@ export class AdminController { }); } + @Post('profile-data/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async addProfileData( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.adminService.addAssetProfile({ dataSource, symbol }); + } + @Delete('profile-data/:dataSource/:symbol') @UseGuards(AuthGuard('jwt')) public async deleteProfileData( diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index db334344d..9a6f1bf17 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,5 +1,6 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.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'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -14,8 +15,8 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; -import { Injectable } from '@nestjs/common'; -import { AssetSubClass, Prisma, Property } from '@prisma/client'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { groupBy } from 'lodash'; @@ -25,6 +26,7 @@ export class AdminService { public constructor( private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, @@ -35,6 +37,38 @@ export class AdminService { this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); } + public async addAssetProfile({ + dataSource, + symbol + }: UniqueAsset): Promise { + try { + const assetProfiles = await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfiles[symbol]?.currency) { + throw new BadRequestException( + `Asset profile not found for ${symbol} (${dataSource})` + ); + } + + return await this.symbolProfileService.add( + assetProfiles[symbol] as Prisma.SymbolProfileCreateInput + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new BadRequestException( + `Asset profile of ${symbol} (${dataSource}) already exists` + ); + } + + throw error; + } + } + public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { await this.marketDataService.deleteMany({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol }); 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 a27a0645d..2f90771bd 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list'; export class SymbolProfileService { public constructor(private readonly prismaService: PrismaService) {} + public async add( + assetProfile: Prisma.SymbolProfileCreateInput + ): Promise { + return this.prismaService.symbolProfile.create({ data: assetProfile }); + } + public async delete({ dataSource, symbol }: UniqueAsset) { return this.prismaService.symbolProfile.delete({ where: { dataSource_symbol: { dataSource, symbol } } 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 a45703562..6ce2acd1f 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 @@ -24,6 +24,8 @@ import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component'; import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces'; +import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component'; +import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -99,6 +101,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { dataSource: params['dataSource'], symbol: params['symbol'] }); + } else if (params['createAssetProfileDialog']) { + this.openCreateAssetProfileDialog(); } }); @@ -241,4 +245,52 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit { }); }); } + + private openCreateAssetProfileDialog() { + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + const dialogRef = this.dialog.open(CreateAssetProfileDialog, { + autoFocus: false, + data: { + deviceType: this.deviceType, + locale: this.user?.settings?.locale + }, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ dataSource, symbol }) => { + if (dataSource && symbol) { + this.adminService + .addAssetProfile({ dataSource, symbol }) + .pipe( + switchMap(() => { + this.isLoading = true; + this.changeDetectorRef.markForCheck(); + + return this.dataService.fetchAdminMarketData({ + filters: this.activeFilters + }); + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe(({ marketData }) => { + this.dataSource = new MatTableDataSource(marketData); + this.dataSource.sort = this.sort; + this.isLoading = false; + + this.changeDetectorRef.markForCheck(); + }); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); + }); + } } diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index ec13a8cf8..728d32e8c 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -164,4 +164,16 @@ + +
+ + + +
diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts index 6991a2455..ffb3d4ee2 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts @@ -4,10 +4,12 @@ import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; import { MatSortModule } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; +import { RouterModule } from '@angular/router'; import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; import { AdminMarketDataComponent } from './admin-market-data.component'; import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module'; +import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module'; @NgModule({ declarations: [AdminMarketDataComponent], @@ -15,10 +17,12 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile CommonModule, GfActivitiesFilterModule, GfAssetProfileDialogModule, + GfCreateAssetProfileDialogModule, MatButtonModule, MatMenuModule, MatSortModule, - MatTableModule + MatTableModule, + RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.scss b/apps/client/src/app/components/admin-market-data/admin-market-data.scss index b5b58f67e..50901445b 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.scss +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.scss @@ -2,4 +2,11 @@ :host { display: block; + + .fab-container { + bottom: 2rem; + position: fixed; + right: 2rem; + z-index: 999; + } } diff --git a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts new file mode 100644 index 000000000..c3c2fb2eb --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts @@ -0,0 +1,53 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, + OnDestroy, + OnInit +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + Validators +} from '@angular/forms'; +import { MatDialogRef } from '@angular/material/dialog'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'h-100' }, + selector: 'gf-create-asset-profile-dialog', + templateUrl: 'create-asset-profile-dialog.html' +}) +export class CreateAssetProfileDialog implements OnInit, OnDestroy { + public createAssetProfileForm: FormGroup; + + public constructor( + public readonly adminService: AdminService, + public readonly changeDetectorRef: ChangeDetectorRef, + public readonly dialogRef: MatDialogRef, + public readonly formBuilder: FormBuilder + ) {} + + public ngOnInit() { + this.createAssetProfileForm = this.formBuilder.group({ + searchSymbol: new FormControl(null, [Validators.required]) + }); + } + + public onCancel() { + this.dialogRef.close(); + } + + public onSubmit() { + this.dialogRef.close({ + dataSource: + this.createAssetProfileForm.controls['searchSymbol'].value.dataSource, + symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol + }); + } + + public ngOnDestroy() {} +} diff --git a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html new file mode 100644 index 000000000..16e67bd51 --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html @@ -0,0 +1,25 @@ +
+

Create Asset Profile

+
+ + Name, symbol or ISIN + + +
+
+ + +
+
diff --git a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts new file mode 100644 index 000000000..e99d8f788 --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete'; + +import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component'; + +@NgModule({ + declarations: [CreateAssetProfileDialog], + imports: [ + CommonModule, + FormsModule, + GfSymbolAutocompleteModule, + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + ReactiveFormsModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfCreateAssetProfileDialogModule {} diff --git a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..16be906c9 --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts @@ -0,0 +1,4 @@ +export interface CreateAssetProfileDialogParams { + deviceType: string; + locale: string; +} diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 7d26668e8..8b126898f 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -23,6 +23,13 @@ import { Observable, map } from 'rxjs'; export class AdminService { public constructor(private http: HttpClient) {} + public addAssetProfile({ dataSource, symbol }: UniqueAsset) { + return this.http.post( + `/api/v1/admin/profile-data/${dataSource}/${symbol}`, + null + ); + } + public deleteJob(aId: string) { return this.http.delete(`/api/v1/admin/queue/job/${aId}`); }