Feature/move market data management from admin to dedicated endpoint (#4125)

* Move market data management from admin to dedicated endpoint

* Update changelog
pull/4126/head^2
Thomas Kaul 7 days ago committed by GitHub
parent a776ea8864
commit c3bd433ac9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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/), 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). 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 ## 2.129.0 - 2024-12-14
### Added ### Added

@ -214,6 +214,9 @@ export class AdminController {
}); });
} }
/**
* @deprecated
*/
@Get('market-data/:dataSource/:symbol') @Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -250,6 +253,9 @@ export class AdminController {
} }
} }
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol') @Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

@ -33,6 +33,7 @@ import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.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 { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
@ -84,6 +85,7 @@ import { UserModule } from './user/user.module';
ImportModule, ImportModule,
InfoModule, InfoModule,
LogoModule, LogoModule,
MarketDataModule,
OrderModule, OrderModule,
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,

@ -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<MarketDataDetailsResponse> {
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
});
}
}

@ -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 {}

@ -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;
}

@ -121,8 +121,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
}); });
this.adminService this.dataService
.fetchAdminMarketDataBySymbol({ .fetchMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
symbol: this.data.symbol symbol: this.data.symbol
}) })

@ -1,5 +1,4 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; 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 { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto'; import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
@ -17,7 +16,6 @@ import {
AdminData, AdminData,
AdminJobs, AdminJobs,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails,
AdminUsers, AdminUsers,
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
@ -29,8 +27,8 @@ import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns'; import { format } from 'date-fns';
import { Observable, map, switchMap } from 'rxjs'; import { switchMap } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { DataService } from './data.service'; import { DataService } from './data.service';
@ -125,25 +123,6 @@ export class AdminService {
}); });
} }
public fetchAdminMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): Observable<AdminMarketDataDetails> {
return this.http
.get<any>(`/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() { public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe( return this.fetchAdminData().pipe(
switchMap(({ settings }) => { 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<MarketData>(url, marketData);
}
public postPlatform(aPlatform: CreatePlatformDto) { public postPlatform(aPlatform: CreatePlatformDto) {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform); return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
} }

@ -3,6 +3,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.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 { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { import {
Activities, Activities,
@ -21,7 +22,6 @@ import {
Access, Access,
AccountBalancesResponse, AccountBalancesResponse,
Accounts, Accounts,
AdminMarketDataDetails,
ApiKeyResponse, ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
@ -31,6 +31,7 @@ import {
ImportResponse, ImportResponse,
InfoItem, InfoItem,
LookupResponse, LookupResponse,
MarketDataDetailsResponse,
OAuthResponse, OAuthResponse,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
@ -51,6 +52,7 @@ import { SortDirection } from '@angular/material/sort';
import { import {
AccountBalance, AccountBalance,
DataSource, DataSource,
MarketData,
Order as OrderModel, Order as OrderModel,
Tag Tag
} from '@prisma/client'; } from '@prisma/client';
@ -316,7 +318,7 @@ export class DataService {
public fetchAsset({ public fetchAsset({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier): Observable<AdminMarketDataDetails> { }: AssetProfileIdentifier): Observable<MarketDataDetailsResponse> {
return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe( return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe(
map((data) => { map((data) => {
for (const item of data.marketData) { for (const item of data.marketData) {
@ -431,6 +433,25 @@ export class DataService {
); );
} }
public fetchMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): Observable<MarketDataDetailsResponse> {
return this.http
.get<any>(`/api/v1/market-data/${dataSource}/${symbol}`)
.pipe(
map((data) => {
for (const item of data.marketData) {
item.date = parseISO(item.date);
}
return data;
})
);
}
public fetchSymbolItem({ public fetchSymbolItem({
dataSource, dataSource,
includeHistoricalData, includeHistoricalData,
@ -665,6 +686,20 @@ export class DataService {
return this.http.post('/api/v1/benchmark', benchmark); 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<MarketData>(url, marketData);
}
public postOrder(aOrder: CreateOrderDto) { public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>('/api/v1/order', aOrder); return this.http.post<OrderModel>('/api/v1/order', aOrder);
} }

@ -30,4 +30,5 @@ export interface EnhancedSymbolProfile {
symbolMapping?: { [key: string]: string }; symbolMapping?: { [key: string]: string };
updatedAt: Date; updatedAt: Date;
url?: string; url?: string;
userId?: string;
} }

@ -46,6 +46,7 @@ import type { ResponseError } from './responses/errors.interface';
import type { HistoricalResponse } from './responses/historical-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface';
import type { ImportResponse } from './responses/import-response.interface'; import type { ImportResponse } from './responses/import-response.interface';
import type { LookupResponse } from './responses/lookup-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 { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
@ -97,6 +98,7 @@ export {
LineChartItem, LineChartItem,
LookupItem, LookupItem,
LookupResponse, LookupResponse,
MarketDataDetailsResponse,
OAuthResponse, OAuthResponse,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,

@ -0,0 +1,8 @@
import { MarketData } from '@prisma/client';
import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface';
export interface MarketDataDetailsResponse {
assetProfile: Partial<EnhancedSymbolProfile>;
marketData: MarketData[];
}

@ -10,6 +10,8 @@ export const permissions = {
createAccount: 'createAccount', createAccount: 'createAccount',
createAccountBalance: 'createAccountBalance', createAccountBalance: 'createAccountBalance',
createApiKey: 'createApiKey', createApiKey: 'createApiKey',
createMarketData: 'createMarketData',
createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile',
createOrder: 'createOrder', createOrder: 'createOrder',
createPlatform: 'createPlatform', createPlatform: 'createPlatform',
createTag: 'createTag', createTag: 'createTag',
@ -33,12 +35,16 @@ export const permissions = {
enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', enableSubscriptionInterstitial: 'enableSubscriptionInterstitial',
enableSystemMessage: 'enableSystemMessage', enableSystemMessage: 'enableSystemMessage',
impersonateAllUsers: 'impersonateAllUsers', impersonateAllUsers: 'impersonateAllUsers',
readMarketData: 'readMarketData',
readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile',
readPlatforms: 'readPlatforms', readPlatforms: 'readPlatforms',
readTags: 'readTags', readTags: 'readTags',
reportDataGlitch: 'reportDataGlitch', reportDataGlitch: 'reportDataGlitch',
toggleReadOnlyMode: 'toggleReadOnlyMode', toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount', updateAccount: 'updateAccount',
updateAuthDevice: 'updateAuthDevice', updateAuthDevice: 'updateAuthDevice',
updateMarketData: 'updateMarketData',
updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile',
updateOrder: 'updateOrder', updateOrder: 'updateOrder',
updatePlatform: 'updatePlatform', updatePlatform: 'updatePlatform',
updateTag: 'updateTag', updateTag: 'updateTag',
@ -57,6 +63,8 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount, permissions.createAccount,
permissions.createAccountBalance, permissions.createAccountBalance,
permissions.deleteAccountBalance, permissions.deleteAccountBalance,
permissions.createMarketData,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder, permissions.createOrder,
permissions.createPlatform, permissions.createPlatform,
permissions.createTag, permissions.createTag,
@ -68,10 +76,14 @@ export function getPermissions(aRole: Role): string[] {
permissions.deletePlatform, permissions.deletePlatform,
permissions.deleteTag, permissions.deleteTag,
permissions.deleteUser, permissions.deleteUser,
permissions.readMarketData,
permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms, permissions.readPlatforms,
permissions.readTags, permissions.readTags,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketData,
permissions.updateMarketDataOfOwnAssetProfile,
permissions.updateOrder, permissions.updateOrder,
permissions.updatePlatform, permissions.updatePlatform,
permissions.updateTag, permissions.updateTag,
@ -93,6 +105,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccess, permissions.createAccess,
permissions.createAccount, permissions.createAccount,
permissions.createAccountBalance, permissions.createAccountBalance,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder, permissions.createOrder,
permissions.deleteAccess, permissions.deleteAccess,
permissions.deleteAccount, permissions.deleteAccount,
@ -100,8 +113,10 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.deleteOwnUser, permissions.deleteOwnUser,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketDataOfOwnAssetProfile,
permissions.updateOrder, permissions.updateOrder,
permissions.updateUserSettings, permissions.updateUserSettings,
permissions.updateViewMode permissions.updateViewMode

@ -1,4 +1,5 @@
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
@ -51,6 +52,7 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) @Inject(MAT_DIALOG_DATA)
public data: HistoricalMarketDataEditorDialogParams, public data: HistoricalMarketDataEditorDialogParams,
private dataService: DataService,
private dateAdapter: DateAdapter<any>, private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>, public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>,
@Inject(MAT_DATE_LOCALE) private locale: string @Inject(MAT_DATE_LOCALE) private locale: string
@ -81,7 +83,7 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy {
} }
public onUpdate() { public onUpdate() {
this.adminService this.dataService
.postMarketData({ .postMarketData({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
marketData: { marketData: {

@ -1,5 +1,5 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; 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 { import {
DATE_FORMAT, DATE_FORMAT,
getDateFormatString, getDateFormatString,
@ -90,7 +90,7 @@ export class GfHistoricalMarketDataEditorComponent
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@ -236,7 +236,7 @@ export class GfHistoricalMarketDataEditorComponent
} }
).data as UpdateMarketDataDto[]; ).data as UpdateMarketDataDto[];
this.adminService this.dataService
.postMarketData({ .postMarketData({
dataSource: this.dataSource, dataSource: this.dataSource,
marketData: { marketData: {

Loading…
Cancel
Save