diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc267496..eb67cd23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles` - Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel - Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel - Improved the sorting to be case-insensitive in the platform management of the admin control panel diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 1a2c58d1c..9eb045fba 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,7 +1,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; -import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; @@ -24,18 +23,13 @@ import { } from '@ghostfolio/common/helper'; import { AdminData, - AdminMarketData, AdminUserResponse, AdminUsersResponse, EnhancedSymbolProfile, ScraperConfiguration } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { - DateRange, - MarketDataPreset, - RequestWithUser -} from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -67,7 +61,6 @@ export class AdminController { public constructor( private readonly adminService: AdminService, - private readonly apiService: ApiService, private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly demoService: DemoService, @@ -218,35 +211,6 @@ export class AdminController { }); } - @Get('market-data') - @HasPermission(permissions.accessAdminControl) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async getMarketData( - @Query('assetSubClasses') filterByAssetSubClasses?: string, - @Query('dataSource') filterByDataSource?: string, - @Query('presetId') presetId?: MarketDataPreset, - @Query('query') filterBySearchQuery?: string, - @Query('skip') skip?: number, - @Query('sortColumn') sortColumn?: string, - @Query('sortDirection') sortDirection?: Prisma.SortOrder, - @Query('take') take?: number - ): Promise { - const filters = this.apiService.buildFiltersFromQueryParams({ - filterByAssetSubClasses, - filterByDataSource, - filterBySearchQuery - }); - - return this.adminService.getMarketData({ - filters, - presetId, - sortColumn, - sortDirection, - skip: isNaN(skip) ? undefined : skip, - take: isNaN(take) ? undefined : take - }); - } - @HasPermission(permissions.accessAdminControl) @Post('market-data/:dataSource/:symbol/test') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index e87df9e74..0cd4d3c16 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -1,6 +1,5 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; -import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; @@ -21,7 +20,6 @@ import { QueueModule } from './queue/queue.module'; @Module({ imports: [ ActivitiesModule, - ApiModule, BenchmarkModule, ConfigurationModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index be6f050c4..46ab25e96 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,6 +1,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { environment } from '@ghostfolio/api/environments/environment'; -import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -13,24 +12,15 @@ import { PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; -import { - applyAssetProfileOverrides, - getAssetProfileIdentifier, - getCurrencyFromSymbol, - isCurrency -} from '@ghostfolio/common/helper'; +import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { AdminData, - AdminMarketData, AdminMarketDataDetails, - AdminMarketDataItem, AdminUserResponse, AdminUsersResponse, AssetProfileIdentifier, - EnhancedSymbolProfile, - Filter + EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; -import { MarketDataPreset } from '@ghostfolio/common/types'; import { BadRequestException, @@ -48,13 +38,11 @@ import { } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { groupBy } from 'lodash'; @Injectable() export class AdminService { public constructor( private readonly activitiesService: ActivitiesService, - private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -188,244 +176,6 @@ export class AdminService { }; } - public async getMarketData({ - filters, - presetId, - sortColumn, - sortDirection = 'asc', - skip, - take = Number.MAX_SAFE_INTEGER - }: { - filters?: Filter[]; - presetId?: MarketDataPreset; - skip?: number; - sortColumn?: string; - sortDirection?: Prisma.SortOrder; - take?: number; - }): Promise { - let orderBy: Prisma.Enumerable = - [{ symbol: 'asc' }]; - const where: Prisma.SymbolProfileWhereInput = {}; - - if (presetId === 'BENCHMARKS') { - const benchmarkAssetProfiles = - await this.benchmarkService.getBenchmarkAssetProfiles(); - - where.id = { - in: benchmarkAssetProfiles.map(({ id }) => { - return id; - }) - }; - } else if (presetId === 'CURRENCIES') { - return this.getMarketDataForCurrencies(); - } else if ( - presetId === 'ETF_WITHOUT_COUNTRIES' || - presetId === 'ETF_WITHOUT_SECTORS' - ) { - filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; - } else if (presetId === 'NO_ACTIVITIES') { - where.activities = { - none: {} - }; - } - - const searchQuery = filters.find(({ type }) => { - return type === 'SEARCH_QUERY'; - })?.id; - - const { - ASSET_SUB_CLASS: filtersByAssetSubClass, - DATA_SOURCE: filtersByDataSource - } = groupBy(filters, ({ type }) => { - return type; - }); - - const marketDataItems = await this.prismaService.marketData.groupBy({ - _count: true, - by: ['dataSource', 'symbol'] - }); - - if (filtersByAssetSubClass) { - where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; - } - - if (filtersByDataSource) { - where.dataSource = DataSource[filtersByDataSource[0].id]; - } - - if (searchQuery) { - where.OR = [ - { id: { mode: 'insensitive', startsWith: searchQuery } }, - { isin: { mode: 'insensitive', startsWith: searchQuery } }, - { name: { mode: 'insensitive', startsWith: searchQuery } }, - { symbol: { mode: 'insensitive', startsWith: searchQuery } } - ]; - } - - if (sortColumn) { - orderBy = [{ [sortColumn]: sortDirection }]; - - if (sortColumn === 'activitiesCount') { - orderBy = [ - { - activities: { - _count: sortDirection - } - } - ]; - } - } - - const extendedPrismaClient = this.getExtendedPrismaClient(); - - const symbolProfileResult = await Promise.all([ - extendedPrismaClient.symbolProfile.findMany({ - skip, - take, - where, - orderBy: [...orderBy, { id: sortDirection }], - select: { - _count: { - select: { - activities: true, - watchedBy: true - } - }, - activities: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 - }, - assetClass: true, - assetSubClass: true, - comment: true, - countries: true, - currency: true, - dataSource: true, - id: true, - isActive: true, - isUsedByUsersWithSubscription: true, - name: true, - scraperConfiguration: true, - sectors: true, - symbol: true, - SymbolProfileOverrides: true - } - }), - this.prismaService.symbolProfile.count({ where }) - ]); - const assetProfiles = symbolProfileResult[0]; - let count = symbolProfileResult[1]; - - const lastMarketPrices = await this.prismaService.marketData.findMany({ - distinct: ['dataSource', 'symbol'], - orderBy: { date: 'desc' }, - select: { - dataSource: true, - marketPrice: true, - symbol: true - }, - where: { - dataSource: { - in: assetProfiles.map(({ dataSource }) => { - return dataSource; - }) - }, - symbol: { - in: assetProfiles.map(({ symbol }) => { - return symbol; - }) - } - } - }); - - const lastMarketPriceMap = new Map(); - - for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { - lastMarketPriceMap.set( - getAssetProfileIdentifier({ dataSource, symbol }), - marketPrice - ); - } - - let marketData: AdminMarketDataItem[] = await Promise.all( - assetProfiles.map(async (assetProfile) => { - const { - _count, - activities, - comment, - currency, - dataSource, - id, - isActive, - isUsedByUsersWithSubscription, - symbol - } = assetProfile; - - const { assetClass, assetSubClass, countries, name, sectors } = - applyAssetProfileOverrides( - assetProfile, - assetProfile.SymbolProfileOverrides - ); - - const countriesCount = countries ? Object.keys(countries).length : 0; - - const lastMarketPrice = lastMarketPriceMap.get( - getAssetProfileIdentifier({ dataSource, symbol }) - ); - - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - - const sectorsCount = sectors ? Object.keys(sectors).length : 0; - - return { - assetClass, - assetSubClass, - comment, - countriesCount, - currency, - dataSource, - id, - isActive, - lastMarketPrice, - marketDataItemCount, - name, - sectorsCount, - symbol, - activitiesCount: _count.activities, - date: activities?.[0]?.date, - isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, - watchedByCount: _count.watchedBy - }; - }) - ); - - if (presetId) { - if (presetId === 'ETF_WITHOUT_COUNTRIES') { - marketData = marketData.filter(({ countriesCount }) => { - return countriesCount === 0; - }); - } else if (presetId === 'ETF_WITHOUT_SECTORS') { - marketData = marketData.filter(({ sectorsCount }) => { - return sectorsCount === 0; - }); - } - - count = marketData.length; - } - - return { - count, - marketData - }; - } - public async getMarketDataBySymbol({ dataSource, symbol @@ -667,138 +417,6 @@ export class AdminService { }); } - private getExtendedPrismaClient() { - const symbolProfileExtension = Prisma.defineExtension((client) => { - return client.$extends({ - result: { - symbolProfile: { - isUsedByUsersWithSubscription: { - compute: async ({ id }) => { - const { _count } = - await this.prismaService.symbolProfile.findUnique({ - select: { - _count: { - select: { - activities: { - where: { - user: { - subscriptions: { - some: { - expiresAt: { - gt: new Date() - } - } - } - } - } - } - } - } - }, - where: { - id - } - }); - - return _count.activities > 0; - } - } - } - } - }); - }); - - return this.prismaService.$extends(symbolProfileExtension); - } - - private async getMarketDataForCurrencies(): Promise { - const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); - - const [lastMarketPrices, marketDataItems] = await Promise.all([ - this.prismaService.marketData.findMany({ - distinct: ['dataSource', 'symbol'], - orderBy: { date: 'desc' }, - select: { - dataSource: true, - marketPrice: true, - symbol: true - }, - where: { - dataSource: { - in: currencyPairs.map(({ dataSource }) => { - return dataSource; - }) - }, - symbol: { - in: currencyPairs.map(({ symbol }) => { - return symbol; - }) - } - } - }), - this.prismaService.marketData.groupBy({ - _count: true, - by: ['dataSource', 'symbol'] - }) - ]); - - const lastMarketPriceMap = new Map(); - - for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { - lastMarketPriceMap.set( - getAssetProfileIdentifier({ dataSource, symbol }), - marketPrice - ); - } - - const marketDataPromise: Promise[] = currencyPairs.map( - async ({ dataSource, symbol }) => { - let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; - let currency: EnhancedSymbolProfile['currency'] = '-'; - let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; - - if (isCurrency(getCurrencyFromSymbol(symbol))) { - currency = getCurrencyFromSymbol(symbol); - ({ activitiesCount, dateOfFirstActivity } = - await this.activitiesService.getStatisticsByCurrency(currency)); - } - - const lastMarketPrice = lastMarketPriceMap.get( - getAssetProfileIdentifier({ dataSource, symbol }) - ); - - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - - return { - activitiesCount, - currency, - dataSource, - lastMarketPrice, - marketDataItemCount, - symbol, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, - countriesCount: 0, - date: dateOfFirstActivity, - id: undefined, - isActive: true, - name: symbol, - sectorsCount: 0, - watchedByCount: 0 - }; - } - ); - - const marketData = await Promise.all(marketDataPromise); - return { marketData, count: marketData.length }; - } - private async getUsersWithAnalytics({ skip, take, diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts index 38227c555..2606d8075 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -1,22 +1,28 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; -import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; +import { + AssetProfilesResponse, + EnhancedSymbolProfile +} from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import { RequestWithUser } from '@ghostfolio/common/types'; +import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, + Get, HttpException, Inject, Param, Patch, + Query, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { DataSource } from '@prisma/client'; +import { DataSource, Prisma } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AssetProfilesService } from './asset-profiles.service'; @@ -24,10 +30,40 @@ import { AssetProfilesService } from './asset-profiles.service'; @Controller('asset-profiles') export class AssetProfilesController { public constructor( + private readonly apiService: ApiService, private readonly assetProfilesService: AssetProfilesService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} + @Get() + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getAssetProfiles( + @Query('assetSubClasses') filterByAssetSubClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('presetId') presetId?: MarketDataPreset, + @Query('query') filterBySearchQuery?: string, + @Query('skip') skip?: number, + @Query('sortColumn') sortColumn?: string, + @Query('sortDirection') sortDirection?: Prisma.SortOrder, + @Query('take') take?: number + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAssetSubClasses, + filterByDataSource, + filterBySearchQuery + }); + + return this.assetProfilesService.getAssetProfiles({ + filters, + presetId, + sortColumn, + sortDirection, + skip: isNaN(skip) ? undefined : skip, + take: isNaN(take) ? undefined : take + }); + } + @HasPermission(permissions.accessAdminControl) @Patch(':dataSource/:symbol') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts index 32b9ab393..98463ce5d 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts @@ -1,3 +1,8 @@ +import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { Module } from '@nestjs/common'; @@ -7,7 +12,14 @@ import { AssetProfilesService } from './asset-profiles.service'; @Module({ controllers: [AssetProfilesController], - imports: [SymbolProfileModule], + imports: [ + ActivitiesModule, + ApiModule, + BenchmarkModule, + ExchangeRateDataModule, + PrismaModule, + SymbolProfileModule + ], providers: [AssetProfilesService] }) export class AssetProfilesModule {} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index ef24372af..39eaa1642 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -1,19 +1,279 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import { + applyAssetProfileOverrides, + getAssetProfileIdentifier, + getCurrencyFromSymbol, + isCurrency +} from '@ghostfolio/common/helper'; +import { + AssetProfileItem, AssetProfileIdentifier, - EnhancedSymbolProfile + AssetProfilesResponse, + EnhancedSymbolProfile, + Filter } from '@ghostfolio/common/interfaces'; +import { MarketDataPreset } from '@ghostfolio/common/types'; import { Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; +import { groupBy } from 'lodash'; @Injectable() export class AssetProfilesService { public constructor( + private readonly activitiesService: ActivitiesService, + private readonly benchmarkService: BenchmarkService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService ) {} + public async getAssetProfiles({ + filters = [], + presetId, + sortColumn, + sortDirection = 'asc', + skip, + take = Number.MAX_SAFE_INTEGER + }: { + filters?: Filter[]; + presetId?: MarketDataPreset; + skip?: number; + sortColumn?: string; + sortDirection?: Prisma.SortOrder; + take?: number; + }): Promise { + let orderBy: Prisma.Enumerable = + [{ symbol: 'asc' }]; + const where: Prisma.SymbolProfileWhereInput = {}; + + if (presetId === 'BENCHMARKS') { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + where.id = { + in: benchmarkAssetProfiles.map(({ id }) => { + return id; + }) + }; + } else if (presetId === 'CURRENCIES') { + return this.getAssetProfilesForCurrencies(); + } else if ( + presetId === 'ETF_WITHOUT_COUNTRIES' || + presetId === 'ETF_WITHOUT_SECTORS' + ) { + filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; + } else if (presetId === 'NO_ACTIVITIES') { + where.activities = { + none: {} + }; + } + + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + + const { + ASSET_SUB_CLASS: filtersByAssetSubClass, + DATA_SOURCE: filtersByDataSource + } = groupBy(filters, ({ type }) => { + return type; + }); + + const marketDataItems = await this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }); + + if (filtersByAssetSubClass) { + where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; + } + + if (filtersByDataSource) { + where.dataSource = DataSource[filtersByDataSource[0].id]; + } + + if (searchQuery) { + where.OR = [ + { id: { mode: 'insensitive', startsWith: searchQuery } }, + { isin: { mode: 'insensitive', startsWith: searchQuery } }, + { name: { mode: 'insensitive', startsWith: searchQuery } }, + { symbol: { mode: 'insensitive', startsWith: searchQuery } } + ]; + } + + if (sortColumn) { + orderBy = [{ [sortColumn]: sortDirection }]; + + if (sortColumn === 'activitiesCount') { + orderBy = [ + { + activities: { + _count: sortDirection + } + } + ]; + } + } + + const extendedPrismaClient = this.getExtendedPrismaClient(); + + const symbolProfileResult = await Promise.all([ + extendedPrismaClient.symbolProfile.findMany({ + skip, + take, + where, + orderBy: [...orderBy, { id: sortDirection }], + select: { + _count: { + select: { + activities: true, + watchedBy: true + } + }, + activities: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + assetClass: true, + assetSubClass: true, + comment: true, + countries: true, + currency: true, + dataSource: true, + id: true, + isin: true, + isActive: true, + isUsedByUsersWithSubscription: true, + name: true, + scraperConfiguration: true, + sectors: true, + symbol: true, + SymbolProfileOverrides: true + } + }), + this.prismaService.symbolProfile.count({ where }) + ]); + const symbolProfiles = symbolProfileResult[0]; + let count = symbolProfileResult[1]; + + const lastMarketPrices = await this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: symbolProfiles.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: symbolProfiles.map(({ symbol }) => { + return symbol; + }) + } + } + }); + + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + let assetProfiles: AssetProfileItem[] = await Promise.all( + symbolProfiles.map(async (assetProfile) => { + const { + _count, + activities, + comment, + currency, + dataSource, + id, + isin, + isActive, + isUsedByUsersWithSubscription, + symbol + } = assetProfile; + + const { assetClass, assetSubClass, countries, name, sectors } = + applyAssetProfileOverrides( + assetProfile, + assetProfile.SymbolProfileOverrides + ); + + const countriesCount = countries ? Object.keys(countries).length : 0; + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + const sectorsCount = sectors ? Object.keys(sectors).length : 0; + + return { + assetClass, + assetSubClass, + comment, + countriesCount, + currency, + dataSource, + id, + isActive, + isin, + lastMarketPrice, + marketDataItemCount, + name, + sectorsCount, + symbol, + activitiesCount: _count.activities, + date: activities?.[0]?.date, + isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, + watchedByCount: _count.watchedBy + }; + }) + ); + + if (presetId) { + if (presetId === 'ETF_WITHOUT_COUNTRIES') { + assetProfiles = assetProfiles.filter(({ countriesCount }) => { + return countriesCount === 0; + }); + } else if (presetId === 'ETF_WITHOUT_SECTORS') { + assetProfiles = assetProfiles.filter(({ sectorsCount }) => { + return sectorsCount === 0; + }); + } + + count = assetProfiles.length; + } + + return { + assetProfiles, + count + }; + } + public async updateAssetProfileData( { dataSource, symbol }: AssetProfileIdentifier, assetProfileData: UpdateAssetProfileDataDto @@ -87,4 +347,136 @@ export class AssetProfilesService { return data; } + + private async getAssetProfilesForCurrencies(): Promise { + const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); + + const [lastMarketPrices, marketDataItems] = await Promise.all([ + this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: currencyPairs.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: currencyPairs.map(({ symbol }) => { + return symbol; + }) + } + } + }), + this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }) + ]); + + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + const assetProfilePromises: Promise[] = currencyPairs.map( + async ({ dataSource, symbol }) => { + let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; + let currency: EnhancedSymbolProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + + if (isCurrency(getCurrencyFromSymbol(symbol))) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.activitiesService.getStatisticsByCurrency(currency)); + } + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + return { + activitiesCount, + currency, + dataSource, + lastMarketPrice, + marketDataItemCount, + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countriesCount: 0, + date: dateOfFirstActivity, + id: undefined, + isActive: true, + name: symbol, + sectorsCount: 0, + watchedByCount: 0 + }; + } + ); + + const assetProfiles = await Promise.all(assetProfilePromises); + return { assetProfiles, count: assetProfiles.length }; + } + + private getExtendedPrismaClient() { + const symbolProfileExtension = Prisma.defineExtension((client) => { + return client.$extends({ + result: { + symbolProfile: { + isUsedByUsersWithSubscription: { + compute: async ({ id }) => { + const { _count } = + await this.prismaService.symbolProfile.findUnique({ + select: { + _count: { + select: { + activities: { + where: { + user: { + subscriptions: { + some: { + expiresAt: { + gt: new Date() + } + } + } + } + } + } + } + } + }, + where: { + id + } + }); + + return _count.activities > 0; + } + } + } + } + }); + }); + + return this.prismaService.$extends(symbolProfileExtension); + } } 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 a4a9b6e19..f7396eb1d 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 @@ -10,11 +10,11 @@ import { } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, + AssetProfileItem, Filter, InfoItem, User } from '@ghostfolio/common/interfaces'; -import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; @@ -152,7 +152,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { } ]; protected readonly canDeleteAssetProfile = canDeleteAssetProfile; - protected dataSource = new MatTableDataSource(); + protected dataSource = new MatTableDataSource(); protected defaultDateFormat: string; protected readonly displayedColumns: string[] = []; protected readonly filters$ = new Subject(); @@ -160,7 +160,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { protected readonly isUUID = isUUID; protected pageSize = DEFAULT_PAGE_SIZE; protected placeholder = ''; - protected readonly selection = new SelectionModel(true); + protected readonly selection = new SelectionModel(true); protected totalItems = 0; protected user: User; @@ -375,8 +375,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { this.selection.clear(); - this.adminService - .fetchAdminMarketData({ + this.dataService + .fetchAssetProfiles({ sortColumn, sortDirection, filters: this.activeFilters, @@ -384,15 +384,15 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { take: this.pageSize }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ count, marketData }) => { + .subscribe(({ assetProfiles, count }) => { this.totalItems = count; this.dataSource = new MatTableDataSource( - marketData.map((marketDataItem) => { + assetProfiles.map((assetProfile) => { return { - ...marketDataItem, + ...assetProfile, isBenchmark: this.benchmarks.some(({ id }) => { - return id === marketDataItem.id; + return id === assetProfile.id; }) }; }) diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 3ca95d9f6..5bd6671f2 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -44,8 +44,8 @@ import { ghostfolioScraperApiSymbolPrefix } from './config'; import { - AdminMarketDataItem, AssetProfileIdentifier, + AssetProfileItem, Benchmark } from './interfaces'; import { BenchmarkTrend, ColorScheme } from './types'; @@ -149,7 +149,7 @@ export function canDeleteAssetProfile({ symbol, watchedByCount }: Pick< - AdminMarketDataItem, + AssetProfileItem, 'activitiesCount' | 'isBenchmark' | 'symbol' | 'watchedByCount' >): boolean { return ( diff --git a/libs/common/src/lib/interfaces/admin-market-data.interface.ts b/libs/common/src/lib/interfaces/asset-profile-item.interface.ts similarity index 79% rename from libs/common/src/lib/interfaces/admin-market-data.interface.ts rename to libs/common/src/lib/interfaces/asset-profile-item.interface.ts index 953f94e26..14e8471c1 100644 --- a/libs/common/src/lib/interfaces/admin-market-data.interface.ts +++ b/libs/common/src/lib/interfaces/asset-profile-item.interface.ts @@ -1,14 +1,10 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; -export interface AdminMarketData { - count: number; - marketData: AdminMarketDataItem[]; -} - -export interface AdminMarketDataItem { +export interface AssetProfileItem { activitiesCount: number; assetClass?: AssetClass; assetSubClass?: AssetSubClass; + comment?: string; countriesCount: number; currency: string; dataSource: DataSource; @@ -16,6 +12,7 @@ export interface AdminMarketDataItem { id: string; isActive: boolean; isBenchmark?: boolean; + isin?: string; isUsedByUsersWithSubscription?: boolean; lastMarketPrice: number; marketDataItemCount: number; diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 6c076514c..989decdba 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -4,13 +4,10 @@ import type { Activity, ActivityError } from './activities.interface'; import type { AdminData } from './admin-data.interface'; import type { AdminJobs } from './admin-jobs.interface'; import type { AdminMarketDataDetails } from './admin-market-data-details.interface'; -import type { - AdminMarketData, - AdminMarketDataItem -} from './admin-market-data.interface'; import type { AdminUser } from './admin-user.interface'; import type { AssetClassSelectorOption } from './asset-class-selector-option.interface'; import type { AssetProfileIdentifier } from './asset-profile-identifier.interface'; +import type { AssetProfileItem } from './asset-profile-item.interface'; import type { BenchmarkProperty } from './benchmark-property.interface'; import type { Benchmark } from './benchmark.interface'; import type { Coupon } from './coupon.interface'; @@ -50,6 +47,7 @@ import type { AdminUsersResponse } from './responses/admin-users-response.interf import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface'; +import type { AssetProfilesResponse } from './responses/asset-profiles-response.interface'; import type { AssetResponse } from './responses/asset-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface'; @@ -114,9 +112,7 @@ export { ActivityResponse, AdminData, AdminJobs, - AdminMarketData, AdminMarketDataDetails, - AdminMarketDataItem, AdminUser, AdminUserResponse, AdminUsersResponse, @@ -126,6 +122,8 @@ export { AssertionCredentialJSON, AssetClassSelectorOption, AssetProfileIdentifier, + AssetProfileItem, + AssetProfilesResponse, AssetResponse, AttestationCredentialJSON, Benchmark, diff --git a/libs/common/src/lib/interfaces/responses/asset-profiles-response.interface.ts b/libs/common/src/lib/interfaces/responses/asset-profiles-response.interface.ts new file mode 100644 index 000000000..e73e4c64f --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/asset-profiles-response.interface.ts @@ -0,0 +1,6 @@ +import { AssetProfileItem } from '../asset-profile-item.interface'; + +export interface AssetProfilesResponse { + assetProfiles: AssetProfileItem[]; + count: number; +} diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index e52cefcbc..3f8efa6ec 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -3,7 +3,7 @@ import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { AccountWithPlatform, DateRange } from '@ghostfolio/common/types'; -import { AdminService, DataService } from '@ghostfolio/ui/services'; +import { DataService } from '@ghostfolio/ui/services'; import { FocusKeyManager } from '@angular/cdk/a11y'; import { @@ -155,7 +155,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { private preselectionTimeout: ReturnType; public constructor( - private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef @@ -674,8 +673,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { private searchAssetProfiles( aSearchTerm: string ): Observable { - return this.adminService - .fetchAdminMarketData({ + return this.dataService + .fetchAssetProfiles({ filters: [ { id: aSearchTerm, @@ -688,8 +687,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { catchError(() => { return EMPTY; }), - map(({ marketData }) => { - return marketData.map( + map(({ assetProfiles }) => { + return assetProfiles.map( ({ assetSubClass, currency, dataSource, name, symbol }) => { return { currency, diff --git a/libs/ui/src/lib/services/admin.service.ts b/libs/ui/src/lib/services/admin.service.ts index 094001c2f..0eea768d6 100644 --- a/libs/ui/src/lib/services/admin.service.ts +++ b/libs/ui/src/lib/services/admin.service.ts @@ -11,32 +11,26 @@ import { import { AdminData, AdminJobs, - AdminMarketData, AdminUserResponse, AdminUsersResponse, AssetProfileIdentifier, DataProviderGhostfolioStatusResponse, DataProviderHistoricalResponse, - EnhancedSymbolProfile, - Filter + EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; import { DateRange } from '@ghostfolio/common/types'; import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { SortDirection } from '@angular/material/sort'; import { DataSource, MarketData, Platform } from '@prisma/client'; import { JobStatus } from 'bull'; import { isNumber } from 'lodash'; -import { DataService } from './data.service'; - @Injectable({ providedIn: 'root' }) export class AdminService { - private readonly dataService = inject(DataService); private readonly environment = inject(GF_ENVIRONMENT); private readonly http = inject(HttpClient); @@ -81,42 +75,6 @@ export class AdminService { return this.http.get('/api/v1/admin'); } - public fetchAdminMarketData({ - filters, - skip, - sortColumn, - sortDirection, - take - }: { - filters?: Filter[]; - skip?: number; - sortColumn?: string; - sortDirection?: SortDirection; - take: number; - }) { - let params = this.dataService.buildFiltersAsQueryParams({ filters }); - - if (skip) { - params = params.append('skip', skip); - } - - if (sortColumn) { - params = params.append('sortColumn', sortColumn); - } - - if (sortDirection) { - params = params.append('sortDirection', sortDirection); - } - - if (take) { - params = params.append('take', take); - } - - return this.http.get('/api/v1/admin/market-data', { - params - }); - } - public fetchGhostfolioDataProviderStatus(aApiKey: string) { const headers = new HttpHeaders({ [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index a3d8bec98..079ea1bf6 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -28,6 +28,7 @@ import { AiPromptResponse, ApiKeyResponse, AssetProfileIdentifier, + AssetProfilesResponse, AssetResponse, BenchmarkMarketDataDetailsResponse, BenchmarkResponse, @@ -378,6 +379,42 @@ export class DataService { ); } + public fetchAssetProfiles({ + filters, + skip, + sortColumn, + sortDirection, + take + }: { + filters?: Filter[]; + skip?: number; + sortColumn?: string; + sortDirection?: SortDirection; + take: number; + }) { + let params = this.buildFiltersAsQueryParams({ filters }); + + if (skip) { + params = params.append('skip', skip); + } + + if (sortColumn) { + params = params.append('sortColumn', sortColumn); + } + + if (sortDirection) { + params = params.append('sortDirection', sortDirection); + } + + if (take) { + params = params.append('take', take); + } + + return this.http.get('/api/v1/asset-profiles', { + params + }); + } + public fetchBenchmarkForUser({ dataSource, filters,