diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dbe1cace..44fd0c3a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Extracted the market data management from the admin control panel endpoint to a dedicated endpoint + ## 2.129.0 - 2024-12-14 ### Added diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 4cc4d467e..a761bbbae 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -214,6 +214,9 @@ export class AdminController { }); } + /** + * @deprecated + */ @Get('market-data/:dataSource/:symbol') @HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -250,6 +253,9 @@ export class AdminController { } } + /** + * @deprecated + */ @HasPermission(permissions.accessAdminControl) @Post('market-data/:dataSource/:symbol') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index b1a240235..7ac2c5915 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -33,6 +33,7 @@ import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; +import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { PublicModule } from './endpoints/public/public.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; @@ -84,6 +85,7 @@ import { UserModule } from './user/user.module'; ImportModule, InfoModule, LogoModule, + MarketDataModule, OrderModule, PlatformModule, PortfolioModule, diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts new file mode 100644 index 000000000..b4aef807a --- /dev/null +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -0,0 +1,136 @@ +import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Get, + HttpException, + Inject, + Param, + Post, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource, Prisma } from '@prisma/client'; +import { parseISO } from 'date-fns'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto'; + +@Controller('market-data') +export class MarketDataController { + public constructor( + private readonly adminService: AdminService, + private readonly marketDataService: MarketDataService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly symbolProfileService: SymbolProfileService + ) {} + + @Get(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + public async getMarketDataBySymbol( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canReadAllAssetProfiles = hasPermission( + this.request.user.permissions, + permissions.readMarketData + ); + + const canReadOwnAssetProfile = + assetProfile.userId === this.request.user.id && + hasPermission( + this.request.user.permissions, + permissions.readMarketDataOfOwnAssetProfile + ); + + if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { + throw new HttpException( + assetProfile.userId + ? getReasonPhrase(StatusCodes.NOT_FOUND) + : getReasonPhrase(StatusCodes.FORBIDDEN), + assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + ); + } + + return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); + } + + @Post(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + public async updateMarketData( + @Body() data: UpdateBulkMarketDataDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ) { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canUpsertAllAssetProfiles = + hasPermission( + this.request.user.permissions, + permissions.createMarketData + ) && + hasPermission( + this.request.user.permissions, + permissions.updateMarketData + ); + + const canUpsertOwnAssetProfile = + assetProfile.userId === this.request.user.id && + hasPermission( + this.request.user.permissions, + permissions.createMarketDataOfOwnAssetProfile + ) && + hasPermission( + this.request.user.permissions, + permissions.updateMarketDataOfOwnAssetProfile + ); + + if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( + ({ date, marketPrice }) => ({ + dataSource, + marketPrice, + symbol, + date: parseISO(date), + state: 'CLOSE' + }) + ); + + return this.marketDataService.updateMany({ + data: dataBulkUpdate + }); + } +} diff --git a/apps/api/src/app/endpoints/market-data/market-data.module.ts b/apps/api/src/app/endpoints/market-data/market-data.module.ts new file mode 100644 index 000000000..2050889fd --- /dev/null +++ b/apps/api/src/app/endpoints/market-data/market-data.module.ts @@ -0,0 +1,13 @@ +import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; +import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { MarketDataController } from './market-data.controller'; + +@Module({ + controllers: [MarketDataController], + imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule] +}) +export class MarketDataModule {} diff --git a/apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts b/apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts new file mode 100644 index 000000000..d07b189b2 --- /dev/null +++ b/apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts @@ -0,0 +1,24 @@ +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsISO8601, + IsNumber, + IsOptional +} from 'class-validator'; + +export class UpdateBulkMarketDataDto { + @ArrayNotEmpty() + @IsArray() + @Type(() => UpdateMarketDataDto) + marketData: UpdateMarketDataDto[]; +} + +class UpdateMarketDataDto { + @IsISO8601() + @IsOptional() + date?: string; + + @IsNumber() + marketPrice: number; +} diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 4fdc22986..a271915f3 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -121,8 +121,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit { } }); - this.adminService - .fetchAdminMarketDataBySymbol({ + this.dataService + .fetchMarketDataBySymbol({ dataSource: this.data.dataSource, symbol: this.data.symbol }) diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 77d135f57..ec0605bee 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -1,5 +1,4 @@ import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; -import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto'; import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto'; import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto'; import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; @@ -17,7 +16,6 @@ import { AdminData, AdminJobs, AdminMarketData, - AdminMarketDataDetails, AdminUsers, DataProviderGhostfolioStatusResponse, EnhancedSymbolProfile, @@ -29,8 +27,8 @@ import { Injectable } from '@angular/core'; import { SortDirection } from '@angular/material/sort'; import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; import { JobStatus } from 'bull'; -import { format, parseISO } from 'date-fns'; -import { Observable, map, switchMap } from 'rxjs'; +import { format } from 'date-fns'; +import { switchMap } from 'rxjs'; import { environment } from '../../environments/environment'; import { DataService } from './data.service'; @@ -125,25 +123,6 @@ export class AdminService { }); } - public fetchAdminMarketDataBySymbol({ - dataSource, - symbol - }: { - dataSource: DataSource; - symbol: string; - }): Observable { - return this.http - .get(`/api/v1/admin/market-data/${dataSource}/${symbol}`) - .pipe( - map((data) => { - for (const item of data.marketData) { - item.date = parseISO(item.date); - } - return data; - }) - ); - } - public fetchGhostfolioDataProviderStatus() { return this.fetchAdminData().pipe( switchMap(({ settings }) => { @@ -278,20 +257,6 @@ export class AdminService { ); } - public postMarketData({ - dataSource, - marketData, - symbol - }: { - dataSource: DataSource; - marketData: UpdateBulkMarketDataDto; - symbol: string; - }) { - const url = `/api/v1/admin/market-data/${dataSource}/${symbol}`; - - return this.http.post(url, marketData); - } - public postPlatform(aPlatform: CreatePlatformDto) { return this.http.post(`/api/v1/platform`, aPlatform); } diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index eef258a5c..269a03e31 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -3,6 +3,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; +import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activities, @@ -21,7 +22,6 @@ import { Access, AccountBalancesResponse, Accounts, - AdminMarketDataDetails, ApiKeyResponse, AssetProfileIdentifier, BenchmarkMarketDataDetails, @@ -31,6 +31,7 @@ import { ImportResponse, InfoItem, LookupResponse, + MarketDataDetailsResponse, OAuthResponse, PortfolioDetails, PortfolioDividends, @@ -51,6 +52,7 @@ import { SortDirection } from '@angular/material/sort'; import { AccountBalance, DataSource, + MarketData, Order as OrderModel, Tag } from '@prisma/client'; @@ -316,7 +318,7 @@ export class DataService { public fetchAsset({ dataSource, symbol - }: AssetProfileIdentifier): Observable { + }: AssetProfileIdentifier): Observable { return this.http.get(`/api/v1/asset/${dataSource}/${symbol}`).pipe( map((data) => { for (const item of data.marketData) { @@ -431,6 +433,25 @@ export class DataService { ); } + public fetchMarketDataBySymbol({ + dataSource, + symbol + }: { + dataSource: DataSource; + symbol: string; + }): Observable { + return this.http + .get(`/api/v1/market-data/${dataSource}/${symbol}`) + .pipe( + map((data) => { + for (const item of data.marketData) { + item.date = parseISO(item.date); + } + return data; + }) + ); + } + public fetchSymbolItem({ dataSource, includeHistoricalData, @@ -665,6 +686,20 @@ export class DataService { return this.http.post('/api/v1/benchmark', benchmark); } + public postMarketData({ + dataSource, + marketData, + symbol + }: { + dataSource: DataSource; + marketData: UpdateBulkMarketDataDto; + symbol: string; + }) { + const url = `/api/v1/market-data/${dataSource}/${symbol}`; + + return this.http.post(url, marketData); + } + public postOrder(aOrder: CreateOrderDto) { return this.http.post('/api/v1/order', aOrder); } diff --git a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts index e7fc4c5b5..ce585c6a3 100644 --- a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts +++ b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts @@ -30,4 +30,5 @@ export interface EnhancedSymbolProfile { symbolMapping?: { [key: string]: string }; updatedAt: Date; url?: string; + userId?: string; } diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index ed8fd4f2a..fa5eb25a5 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -46,6 +46,7 @@ import type { ResponseError } from './responses/errors.interface'; import type { HistoricalResponse } from './responses/historical-response.interface'; import type { ImportResponse } from './responses/import-response.interface'; import type { LookupResponse } from './responses/lookup-response.interface'; +import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; @@ -97,6 +98,7 @@ export { LineChartItem, LookupItem, LookupResponse, + MarketDataDetailsResponse, OAuthResponse, PortfolioChart, PortfolioDetails, diff --git a/libs/common/src/lib/interfaces/responses/market-data-details-response.interface.ts b/libs/common/src/lib/interfaces/responses/market-data-details-response.interface.ts new file mode 100644 index 000000000..bbf947301 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/market-data-details-response.interface.ts @@ -0,0 +1,8 @@ +import { MarketData } from '@prisma/client'; + +import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface'; + +export interface MarketDataDetailsResponse { + assetProfile: Partial; + marketData: MarketData[]; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index cfee1c9e8..d6676ec4e 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -10,6 +10,8 @@ export const permissions = { createAccount: 'createAccount', createAccountBalance: 'createAccountBalance', createApiKey: 'createApiKey', + createMarketData: 'createMarketData', + createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile', createOrder: 'createOrder', createPlatform: 'createPlatform', createTag: 'createTag', @@ -33,12 +35,16 @@ export const permissions = { enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', enableSystemMessage: 'enableSystemMessage', impersonateAllUsers: 'impersonateAllUsers', + readMarketData: 'readMarketData', + readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readPlatforms: 'readPlatforms', readTags: 'readTags', reportDataGlitch: 'reportDataGlitch', toggleReadOnlyMode: 'toggleReadOnlyMode', updateAccount: 'updateAccount', updateAuthDevice: 'updateAuthDevice', + updateMarketData: 'updateMarketData', + updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateOrder: 'updateOrder', updatePlatform: 'updatePlatform', updateTag: 'updateTag', @@ -57,6 +63,8 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createAccountBalance, permissions.deleteAccountBalance, + permissions.createMarketData, + permissions.createMarketDataOfOwnAssetProfile, permissions.createOrder, permissions.createPlatform, permissions.createTag, @@ -68,10 +76,14 @@ export function getPermissions(aRole: Role): string[] { permissions.deletePlatform, permissions.deleteTag, permissions.deleteUser, + permissions.readMarketData, + permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, permissions.readTags, permissions.updateAccount, permissions.updateAuthDevice, + permissions.updateMarketData, + permissions.updateMarketDataOfOwnAssetProfile, permissions.updateOrder, permissions.updatePlatform, permissions.updateTag, @@ -93,6 +105,7 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccess, permissions.createAccount, permissions.createAccountBalance, + permissions.createMarketDataOfOwnAssetProfile, permissions.createOrder, permissions.deleteAccess, permissions.deleteAccount, @@ -100,8 +113,10 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAuthDevice, permissions.deleteOrder, permissions.deleteOwnUser, + permissions.readMarketDataOfOwnAssetProfile, permissions.updateAccount, permissions.updateAuthDevice, + permissions.updateMarketDataOfOwnAssetProfile, permissions.updateOrder, permissions.updateUserSettings, permissions.updateViewMode diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts index 434266e1e..69105ac94 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts @@ -1,4 +1,5 @@ import { AdminService } from '@ghostfolio/client/services/admin.service'; +import { DataService } from '@ghostfolio/client/services/data.service'; import { CommonModule } from '@angular/common'; import { @@ -51,6 +52,7 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy { private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: HistoricalMarketDataEditorDialogParams, + private dataService: DataService, private dateAdapter: DateAdapter, public dialogRef: MatDialogRef, @Inject(MAT_DATE_LOCALE) private locale: string @@ -81,7 +83,7 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy { } public onUpdate() { - this.adminService + this.dataService .postMarketData({ dataSource: this.data.dataSource, marketData: { diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts index 0fce78621..c9725e3ee 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts @@ -1,5 +1,5 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; -import { AdminService } from '@ghostfolio/client/services/admin.service'; +import { DataService } from '@ghostfolio/client/services/data.service'; import { DATE_FORMAT, getDateFormatString, @@ -90,7 +90,7 @@ export class GfHistoricalMarketDataEditorComponent private unsubscribeSubject = new Subject(); public constructor( - private adminService: AdminService, + private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, private formBuilder: FormBuilder, @@ -236,7 +236,7 @@ export class GfHistoricalMarketDataEditorComponent } ).data as UpdateMarketDataDto[]; - this.adminService + this.dataService .postMarketData({ dataSource: this.dataSource, marketData: {