diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e38b6c9..d1d66e91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the Korean (`ko`) language to the footer +### 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 +- Improved the sorting to be case-insensitive in the tag management of the admin control panel +- Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3` + ### Fixed - Fixed an issue with the localization of the country names 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/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 6d6041c81..0c159bc1c 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -28,11 +28,11 @@ import { DEFAULT_CURRENCY, DEFAULT_DATE_RANGE, DEFAULT_LANGUAGE_CODE, + DEFAULT_LOCALE, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, - TAG_ID_EXCLUDE_FROM_ANALYSIS, - locale as defaultLocale + TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { @@ -102,7 +102,7 @@ export class UserService { public async getUser({ impersonationUserId, - locale = defaultLocale, + locale = DEFAULT_LOCALE, user }: { impersonationUserId: string; 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 805adf89d..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 @@ -1,8 +1,8 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_COLOR_SCHEME, - DEFAULT_PAGE_SIZE, - locale + DEFAULT_LOCALE, + DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { canDeleteAssetProfile, @@ -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; }) }; }) @@ -429,7 +429,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { colorScheme: this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? locale + locale: this.user?.settings?.locale ?? DEFAULT_LOCALE } satisfies AssetProfileDialogParams, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' @@ -464,7 +464,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { autoFocus: false, data: { deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? locale + locale: this.user?.settings?.locale ?? DEFAULT_LOCALE } satisfies CreateAssetProfileDialogParams, width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); 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 63d425513..e86e3e631 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 @@ -239,7 +239,18 @@ [disabled]="!selection.hasValue()" (click)="onDeleteAssetProfiles()" > - Delete Profiles + Delete + {{ + selection.selected.length > 1 + ? selection.selected.length + : '' + }} + {selection.selected.length, plural, + =1 {Profile} + other {Profiles} + } diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts index 77e2151ef..28c8c2d9f 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts @@ -32,6 +32,8 @@ export class AdminMarketDataService { public deleteAssetProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ) { + const assetProfileCount = aAssetProfileIdentifiers.length; + this.notificationService.confirm({ confirmFn: () => { const deleteRequests = aAssetProfileIdentifiers.map( @@ -44,7 +46,10 @@ export class AdminMarketDataService { .pipe( catchError(() => { this.notificationService.alert({ - title: $localize`Oops! Could not delete profiles.` + title: + assetProfileCount === 1 + ? $localize`Oops! Could not delete the asset profile.` + : $localize`Oops! Could not delete the asset profiles.` }); return EMPTY; @@ -56,7 +61,10 @@ export class AdminMarketDataService { .subscribe(); }, confirmType: ConfirmationDialogType.Warn, - title: $localize`Do you really want to delete these profiles?` + title: + assetProfileCount === 1 + ? $localize`Do you really want to delete this asset profile?` + : $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?` }); } } diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.ts b/apps/client/src/app/components/admin-platform/admin-platform.component.ts index a9d135068..26e6c2b1e 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.ts +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.ts @@ -1,7 +1,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; -import { getLocale } from '@ghostfolio/common/helper'; +import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { AdminService, DataService } from '@ghostfolio/ui/services'; @@ -33,7 +33,6 @@ import { ellipsisHorizontal, trashOutline } from 'ionicons/icons'; -import { get } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component'; @@ -147,7 +146,7 @@ export class GfAdminPlatformComponent implements OnInit { this.dataSource = new MatTableDataSource(platforms); this.dataSource.sort = this.sort(); - this.dataSource.sortingDataAccessor = get; + this.dataSource.sortingDataAccessor = getLowercase; this.dataService.updateInfo(); diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.ts b/apps/client/src/app/components/admin-tag/admin-tag.component.ts index c00fe3a61..fcb901707 100644 --- a/apps/client/src/app/components/admin-tag/admin-tag.component.ts +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.ts @@ -1,7 +1,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; -import { getLocale } from '@ghostfolio/common/helper'; +import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; @@ -32,7 +32,6 @@ import { ellipsisHorizontal, trashOutline } from 'ionicons/icons'; -import { get } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { GfCreateOrUpdateTagDialogComponent } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component'; @@ -149,7 +148,7 @@ export class GfAdminTagComponent implements OnInit { this.dataSource = new MatTableDataSource(this.tags); this.dataSource.sort = this.sort(); - this.dataSource.sortingDataAccessor = get; + this.dataSource.sortingDataAccessor = getLowercase; this.dataService.updateInfo(); diff --git a/apps/client/src/app/components/admin-users/admin-users.component.ts b/apps/client/src/app/components/admin-users/admin-users.component.ts index f477776a5..15133b781 100644 --- a/apps/client/src/app/components/admin-users/admin-users.component.ts +++ b/apps/client/src/app/components/admin-users/admin-users.component.ts @@ -5,7 +5,7 @@ import { import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config'; +import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { getDateFnsLocale, @@ -317,7 +317,7 @@ export class GfAdminUsersComponent implements OnInit { currentUserId: this.user?.id, deviceType: this.deviceType(), hasPermissionForSubscription: this.hasPermissionForSubscription, - locale: this.user?.settings?.locale ?? locale, + locale: this.user?.settings?.locale ?? DEFAULT_LOCALE, userId: aUserId } satisfies UserDetailDialogParams, height: this.deviceType() === 'mobile' ? '98vh' : '60vh', diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts index 22d829daa..0107f36a2 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -1,6 +1,6 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { locale as defaultLocale } from '@ghostfolio/common/config'; +import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { AssetProfileIdentifier, Benchmark, @@ -149,7 +149,7 @@ export class GfHomeWatchlistComponent implements OnInit { autoFocus: false, data: { deviceType: this.deviceType(), - locale: this.user?.settings?.locale ?? defaultLocale + locale: this.user?.settings?.locale ?? DEFAULT_LOCALE }, width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index decb30682..ba17dbef3 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -1,6 +1,5 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config'; -import { locale as defaultLocale } from '@ghostfolio/common/config'; +import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { getDateFormatString } from '@ghostfolio/common/helper'; import { @@ -120,7 +119,7 @@ export class GfCreateOrUpdateActivityDialogComponent { this.hasPermissionToCreateOwnTag = this.data.user?.settings?.isExperimentalFeatures && hasPermission(this.data.user?.permissions, permissions.createOwnTag); - this.locale = this.data.user.settings.locale ?? defaultLocale; + this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE; this.mode = this.data.activity?.id ? 'update' : 'create'; this.dateAdapter.setLocale(this.locale); diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 3e7fbe6df..4924aaeea 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -13,8 +13,6 @@ export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPr export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`; export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`; -export const locale = 'en-US'; - export const primaryColorHex = '#36cfcc'; export const primaryColorRgb = { r: 54, @@ -85,6 +83,7 @@ export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_RANGE: DateRange = 'max'; export const DEFAULT_HOST = '0.0.0.0'; export const DEFAULT_LANGUAGE_CODE = 'en'; +export const DEFAULT_LOCALE = 'en-US'; export const DEFAULT_PAGE_SIZE = 50; export const DEFAULT_PORT = 3333; export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index a29129f91..5bd6671f2 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -36,16 +36,16 @@ import { get, isNil, isString } from 'lodash'; import { DEFAULT_CURRENCY, + DEFAULT_LOCALE, DERIVED_CURRENCIES, ghostfolioFearAndGreedIndexSymbol, ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolStocks, - ghostfolioScraperApiSymbolPrefix, - locale + 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 ( @@ -261,7 +261,7 @@ export function getCurrencyFromSymbol(aSymbol = '') { export function getCountryName({ code }: { code: string }): string { try { return ( - new Intl.DisplayNames([document.documentElement.lang || locale], { + new Intl.DisplayNames([document.documentElement.lang || DEFAULT_LOCALE], { type: 'region' }).of(code) ?? code ); @@ -338,7 +338,7 @@ export function getEmojiFlag(aCountryCode: string) { } export function getLocale() { - return navigator.language ?? locale; + return navigator.language ?? DEFAULT_LOCALE; } export function getLowercase(object: object, path: string) { 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/common/src/lib/personal-finance-tools.ts b/libs/common/src/lib/personal-finance-tools.ts index 8698c76f7..744081464 100644 --- a/libs/common/src/lib/personal-finance-tools.ts +++ b/libs/common/src/lib/personal-finance-tools.ts @@ -1,6 +1,17 @@ import { Product } from '@ghostfolio/common/interfaces'; export const personalFinanceTools: Product[] = [ + { + hasFreePlan: true, + hasSelfHostingAbility: true, + key: 'acemoney', + name: 'AceMoney', + note: 'License is a perpetual license', + origin: 'US', + pricingPerYear: '$44.95', + slogan: 'Lifetime Personal Finance Control, One Single Payment.', + url: 'https://www.mechcad.net' + }, { founded: 2023, hasSelfHostingAbility: false, @@ -361,6 +372,16 @@ export const personalFinanceTools: Product[] = [ slogan: 'Your financial superpower', url: 'https://www.etops.com' }, + { + founded: 2015, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'everydollar', + name: 'EveryDollar', + origin: 'US', + slogan: 'Plan, track, find more margin', + url: 'https://www.ramseysolutions.com/money/everydollar' + }, { founded: 2020, hasFreePlan: true, @@ -497,6 +518,17 @@ export const personalFinanceTools: Product[] = [ slogan: 'Take control over your investments', url: 'https://www.folishare.com' }, + { + founded: 1993, + hasSelfHostingAbility: true, + key: 'fund-manager', + name: 'Fund Manager', + note: 'License is a perpetual license', + origin: 'US', + pricingPerYear: '$99', + slogan: 'Powerful portfolio management software', + url: 'https://www.fundmanagersoftware.com' + }, { hasFreePlan: true, hasSelfHostingAbility: false, @@ -528,6 +560,15 @@ export const personalFinanceTools: Product[] = [ slogan: 'Portfolio Tracker, Analysis & Community', url: 'https://www.getquin.com' }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'goodbudget', + name: 'Goodbudget', + origin: 'US', + slogan: 'Budget with a why', + url: 'https://goodbudget.com' + }, { hasFreePlan: true, hasSelfHostingAbility: false, @@ -665,6 +706,16 @@ export const personalFinanceTools: Product[] = [ slogan: 'Sustainability insights for wealth managers', url: 'https://leafs.ch' }, + { + founded: 2020, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'lunch-money', + name: 'Lunch Money', + origin: 'CA', + slogan: 'Delightfully simple personal finance and budgeting', + url: 'https://lunchmoney.app' + }, { founded: 2018, hasFreePlan: false, @@ -725,6 +776,16 @@ export const personalFinanceTools: Product[] = [ slogan: 'The smartest way to track your crypto', url: 'https://www.merlincrypto.com' }, + { + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'mezzi', + name: 'Mezzi', + origin: 'US', + pricingPerYear: '$48', + slogan: 'Self-manage your wealth. Get fiduciary advice.', + url: 'https://www.mezzi.com' + }, { founded: 1991, hasSelfHostingAbility: true, @@ -776,6 +837,15 @@ export const personalFinanceTools: Product[] = [ slogan: 'Have total control of your financial life', url: 'https://www.moneyspire.com' }, + { + hasFreePlan: true, + hasSelfHostingAbility: true, + key: 'moneywell', + name: 'MoneyWell', + origin: 'US', + slogan: 'Personal Budgeting Software for Mac and iOS', + url: 'https://moneywell.app' + }, { key: 'moneywiz', name: 'MoneyWiz', @@ -1109,6 +1179,15 @@ export const personalFinanceTools: Product[] = [ slogan: 'Stock Portfolio Tracker', url: 'https://simpleportfolio.app' }, + { + founded: 2020, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'simplifi', + name: 'Simplifi by Quicken', + origin: 'US', + url: 'https://www.quicken.com/products/simplifi' + }, { founded: 2014, hasFreePlan: true, 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/fire-calculator/fire-calculator.component.stories.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts index 0872c2aac..ad80499d7 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts @@ -1,4 +1,4 @@ -import { locale } from '@ghostfolio/common/config'; +import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -47,7 +47,7 @@ export const Simple: Story = { annualInterestRate: 5, currency: 'USD', fireWealth: 50000, - locale: locale, + locale: DEFAULT_LOCALE, savingsRate: 1000 } }; 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, diff --git a/package-lock.json b/package-lock.json index 7afc46fd8..0fceee455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,7 +95,7 @@ "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", "undici": "7.24.4", - "yahoo-finance2": "3.14.2", + "yahoo-finance2": "3.15.3", "zone.js": "0.16.1" }, "devDependencies": { @@ -4556,7 +4556,6 @@ "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -7344,7 +7343,6 @@ "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "dev": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -18893,7 +18891,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -22011,7 +22008,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -22155,7 +22151,6 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", - "dev": true, "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -23811,7 +23806,6 @@ "version": "4.12.14", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -24553,7 +24547,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -25222,7 +25215,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, "node_modules/isobject": { @@ -28477,7 +28469,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -28652,7 +28643,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -32293,7 +32283,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -32531,7 +32520,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -35418,7 +35406,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -35431,7 +35418,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -39554,7 +39540,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -39879,19 +39864,23 @@ } }, "node_modules/yahoo-finance2": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.2.tgz", - "integrity": "sha512-s+F7TWQT7zAtjhfC7rFHEX16Xfq36u3wceysINP7V+esF3mAYyk9slxZU+fEdkxaTuCT0+PnikHdekMX4UPMrg==", + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.15.3.tgz", + "integrity": "sha512-AFmVZ4ACg3QFT2a/hyJv4Scp2J45gm/6xetVgvUvXzZypqsqbgreXJad4i+qm0onj6yeCf2fPhvow7Eh0CwwzA==", "license": "MIT", "dependencies": { "@deno/shim-deno": "~0.18.0", + "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.26.0", "fetch-mock-cache": "npm:fetch-mock-cache@^2.1.3", "json-schema": "^0.4.0", "tough-cookie": "npm:tough-cookie@^5.1.1", - "tough-cookie-file-store": "npm:tough-cookie-file-store@^2.0.3" + "tough-cookie-file-store": "npm:tough-cookie-file-store@^2.0.3", + "zod": "npm:zod@^3.25.0" }, "bin": { - "yahoo-finance": "esm/bin/yahoo-finance.js" + "yahoo-finance": "esm/bin/yahoo-finance.js", + "yahoo-finance-mcp": "esm/bin/yahoo-finance-mcp.js", + "yahoo-finance2": "esm/bin/yahoo-finance.js" }, "engines": { "node": ">=20.0.0" @@ -40149,7 +40138,6 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" diff --git a/package.json b/package.json index f6f31747b..6ec921f21 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", "undici": "7.24.4", - "yahoo-finance2": "3.14.2", + "yahoo-finance2": "3.15.3", "zone.js": "0.16.1" }, "devDependencies": {