diff --git a/CHANGELOG.md b/CHANGELOG.md index 9edeeebb0..9f39a5210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extracted the symbol search to a dedicated component - Improved the column headers in the holdings table for mobile - Upgraded `prisma` from version `4.14.1` to `4.15.0` +- Added ability to add Asset Profile from Admin Market data ## 1.280.1 - 2023-06-10 diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 3898a8876..461da8b6e 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -26,17 +26,19 @@ 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'; import { AdminService } from './admin.service'; import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateMarketDataDto } from './update-market-data.dto'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; @Controller('admin') export class AdminController { @@ -328,6 +330,28 @@ export class AdminController { }); } + @Post('profile-data/:dataSource/:symbol') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseGuards(AuthGuard('jwt')) + 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..46e53be9e 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -14,10 +14,11 @@ 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'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; @Injectable() export class AdminService { @@ -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 assetProfile = await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile[symbol].currency) { + throw new BadRequestException( + `Asset profile not found for ${symbol} (${dataSource})` + ); + } + + return await this.symbolProfileService.add( + assetProfile[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..bd79dc85d 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,55 @@ 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((data: any) => { + const dataSource = data?.dataSource; + const symbol = data?.symbol; + + if (dataSource && symbol) { + this.adminService + .addAssetProfile({ dataSource, symbol }) + .pipe( + takeUntil(this.unsubscribeSubject), + 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..6b049564a 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 @@ -8,6 +8,8 @@ import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activ 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'; +import { RouterModule } from '@angular/router'; @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..2d86660fd 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 { + position: fixed; + right: 2rem; + bottom: 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..4dcaed6e9 --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts @@ -0,0 +1,58 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, + OnDestroy, + OnInit +} from '@angular/core'; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AdminMarketDataItem } from '@ghostfolio/common/interfaces'; +import { CreateAssetProfileDialogParams } from '@ghostfolio/client/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; + +@Component({ + host: { class: 'h-100' }, + selector: 'gf-create-asset-profile-dialog', + templateUrl: 'create-asset-profile-dialog.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +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 + ) {} + + ngOnInit() { + this.createAssetProfileForm = this.formBuilder.group({ + searchSymbol: new FormControl(null, [Validators.required]) + }); + } + + ngOnDestroy(): void {} + + onSubmit() { + this.dialogRef.close({ + dataSource: + this.createAssetProfileForm.controls['searchSymbol'].value.dataSource, + symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol + }); + } + + onCancel() { + this.dialogRef.close(); + } +} 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..d92c0df6d --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html @@ -0,0 +1,31 @@ +
+

Create Asset Profile

+
+ + Name, symbol or ISIN + + + Asset profile already present + + +
+
+ + +
+
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..728ab3ef2 --- /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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; + +import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete'; + +@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..4567a8a3d --- /dev/null +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts @@ -0,0 +1,7 @@ +import { AdminMarketDataItem } from '@ghostfolio/common/interfaces'; + +export interface CreateAssetProfileDialogParams { + existingSymbols: AdminMarketDataItem[]; + 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}`); }