Thomas Kaul 4 days ago
committed by GitHub
parent
commit
0b26fe3132
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 794
      apps/api/src/app/admin/admin.service.ts
  3. 4
      package-lock.json
  4. 2
      package.json

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.152.0-beta.3 - 2025-04-15
### Changed
- Disabled the extended prisma client in the admin service
## 2.151.0 - 2025-04-11 ## 2.151.0 - 2025-04-11
### Added ### Added

794
apps/api/src/app/admin/admin.service.ts

@ -1,7 +1,7 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; // import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -15,7 +15,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
getAssetProfileIdentifier, // getAssetProfileIdentifier,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency isCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -23,38 +23,39 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, // AdminMarketDataItem,
AdminUsers, AdminUsers,
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; // import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { import {
BadRequestException, BadRequestException,
HttpException, HttpException,
Injectable, Injectable
Logger // Logger
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma, Prisma,
PrismaClient, // PrismaClient,
Property, Property,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
// import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, // private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -151,261 +152,268 @@ export class AdminService {
}; };
} }
public async getMarketData({ public async getMarketData(
filters, {
presetId, // filters,
sortColumn, // presetId,
sortDirection, // sortColumn,
skip, // sortDirection,
take = Number.MAX_SAFE_INTEGER // skip,
}: { // take = Number.MAX_SAFE_INTEGER
filters?: Filter[]; }: {
presetId?: MarketDataPreset; filters?: Filter[];
skip?: number; presetId?: MarketDataPreset;
sortColumn?: string; skip?: number;
sortDirection?: Prisma.SortOrder; sortColumn?: string;
take?: number; sortDirection?: Prisma.SortOrder;
}): Promise<AdminMarketData> { take?: number;
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ 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' }];
} }
): Promise<AdminMarketData> {
const searchQuery = filters.find(({ type }) => { return Promise.resolve({
return type === 'SEARCH_QUERY'; count: 0,
})?.id; marketData: []
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
({ type }) => {
return type;
}
);
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
}); });
if (filtersByAssetSubClass) { // let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; // [{ symbol: 'asc' }];
} // const where: Prisma.SymbolProfileWhereInput = {};
if (searchQuery) { // if (presetId === 'BENCHMARKS') {
where.OR = [ // const benchmarkAssetProfiles =
{ id: { mode: 'insensitive', startsWith: searchQuery } }, // await this.benchmarkService.getBenchmarkAssetProfiles();
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } }, // where.id = {
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } // in: benchmarkAssetProfiles.map(({ id }) => {
]; // return id;
} // })
// };
if (sortColumn) { // } else if (presetId === 'CURRENCIES') {
orderBy = [{ [sortColumn]: sortDirection }]; // return this.getMarketDataForCurrencies();
// } else if (
if (sortColumn === 'activitiesCount') { // presetId === 'ETF_WITHOUT_COUNTRIES' ||
orderBy = { // presetId === 'ETF_WITHOUT_SECTORS'
Order: { // ) {
_count: sortDirection // filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} // }
};
} // const searchQuery = filters.find(({ type }) => {
} // return type === 'SEARCH_QUERY';
// })?.id;
const extendedPrismaClient = this.getExtendedPrismaClient();
// const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
try { // filters,
const symbolProfileResult = await Promise.all([ // ({ type }) => {
extendedPrismaClient.symbolProfile.findMany({ // return type;
orderBy, // }
skip, // );
take,
where, // const marketDataItems = await this.prismaService.marketData.groupBy({
select: { // _count: true,
_count: { // by: ['dataSource', 'symbol']
select: { Order: true } // });
},
assetClass: true, // if (filtersByAssetSubClass) {
assetSubClass: true, // where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
comment: true, // }
countries: true,
currency: true, // if (searchQuery) {
dataSource: true, // where.OR = [
id: true, // { id: { mode: 'insensitive', startsWith: searchQuery } },
isActive: true, // { isin: { mode: 'insensitive', startsWith: searchQuery } },
isUsedByUsersWithSubscription: true, // { name: { mode: 'insensitive', startsWith: searchQuery } },
name: true, // { symbol: { mode: 'insensitive', startsWith: searchQuery } }
Order: { // ];
orderBy: [{ date: 'asc' }], // }
select: { date: true },
take: 1 // if (sortColumn) {
}, // orderBy = [{ [sortColumn]: sortDirection }];
scraperConfiguration: true,
sectors: true, // if (sortColumn === 'activitiesCount') {
symbol: true, // orderBy = {
SymbolProfileOverrides: true // Order: {
} // _count: sortDirection
}), // }
this.prismaService.symbolProfile.count({ where }) // };
]); // }
const assetProfiles = symbolProfileResult[0]; // }
let count = symbolProfileResult[1];
// const extendedPrismaClient = this.getExtendedPrismaClient();
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'], // try {
orderBy: { date: 'desc' }, // const symbolProfileResult = await Promise.all([
select: { // extendedPrismaClient.symbolProfile.findMany({
dataSource: true, // orderBy,
marketPrice: true, // skip,
symbol: true // take,
}, // where,
where: { // select: {
dataSource: { // _count: {
in: assetProfiles.map(({ dataSource }) => { // select: { Order: true }
return dataSource; // },
}) // assetClass: true,
}, // assetSubClass: true,
symbol: { // comment: true,
in: assetProfiles.map(({ symbol }) => { // countries: true,
return symbol; // currency: true,
}) // dataSource: true,
} // id: true,
} // isActive: true,
}); // isUsedByUsersWithSubscription: true,
// name: true,
const lastMarketPriceMap = new Map<string, number>(); // Order: {
// orderBy: [{ date: 'asc' }],
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { // select: { date: true },
lastMarketPriceMap.set( // take: 1
getAssetProfileIdentifier({ dataSource, symbol }), // },
marketPrice // scraperConfiguration: true,
); // sectors: true,
} // symbol: true,
// SymbolProfileOverrides: true
let marketData: AdminMarketDataItem[] = await Promise.all( // }
assetProfiles.map( // }),
async ({ // this.prismaService.symbolProfile.count({ where })
_count, // ]);
assetClass, // const assetProfiles = symbolProfileResult[0];
assetSubClass, // let count = symbolProfileResult[1];
comment,
countries, // const lastMarketPrices = await this.prismaService.marketData.findMany({
currency, // distinct: ['dataSource', 'symbol'],
dataSource, // orderBy: { date: 'desc' },
id, // select: {
isActive, // dataSource: true,
isUsedByUsersWithSubscription, // marketPrice: true,
name, // symbol: true
Order, // },
sectors, // where: {
symbol, // dataSource: {
SymbolProfileOverrides // in: assetProfiles.map(({ dataSource }) => {
}) => { // return dataSource;
let countriesCount = countries ? Object.keys(countries).length : 0; // })
// },
const lastMarketPrice = lastMarketPriceMap.get( // symbol: {
getAssetProfileIdentifier({ dataSource, symbol }) // in: assetProfiles.map(({ symbol }) => {
); // return symbol;
// })
const marketDataItemCount = // }
marketDataItems.find((marketDataItem) => { // }
return ( // });
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol // const lastMarketPriceMap = new Map<string, number>();
);
})?._count ?? 0; // for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
// lastMarketPriceMap.set(
let sectorsCount = sectors ? Object.keys(sectors).length : 0; // getAssetProfileIdentifier({ dataSource, symbol }),
// marketPrice
if (SymbolProfileOverrides) { // );
assetClass = SymbolProfileOverrides.assetClass ?? assetClass; // }
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass; // let marketData: AdminMarketDataItem[] = await Promise.all(
// assetProfiles.map(
if ( // async ({
( // _count,
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray // assetClass,
)?.length > 0 // assetSubClass,
) { // comment,
countriesCount = ( // countries,
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray // currency,
).length; // dataSource,
} // id,
// isActive,
name = SymbolProfileOverrides.name ?? name; // isUsedByUsersWithSubscription,
// name,
if ( // Order,
(SymbolProfileOverrides.sectors as unknown as Sector[]) // sectors,
?.length > 0 // symbol,
) { // SymbolProfileOverrides
sectorsCount = ( // }) => {
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray // let countriesCount = countries ? Object.keys(countries).length : 0;
).length;
} // const lastMarketPrice = lastMarketPriceMap.get(
} // getAssetProfileIdentifier({ dataSource, symbol })
// );
return {
assetClass, // const marketDataItemCount =
assetSubClass, // marketDataItems.find((marketDataItem) => {
comment, // return (
currency, // marketDataItem.dataSource === dataSource &&
countriesCount, // marketDataItem.symbol === symbol
dataSource, // );
id, // })?._count ?? 0;
isActive,
lastMarketPrice, // let sectorsCount = sectors ? Object.keys(sectors).length : 0;
name,
symbol, // if (SymbolProfileOverrides) {
marketDataItemCount, // assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
sectorsCount, // assetSubClass =
activitiesCount: _count.Order, // SymbolProfileOverrides.assetSubClass ?? assetSubClass;
date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription // if (
}; // (
} // SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
) // )?.length > 0
); // ) {
// countriesCount = (
if (presetId) { // SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
if (presetId === 'ETF_WITHOUT_COUNTRIES') { // ).length;
marketData = marketData.filter(({ countriesCount }) => { // }
return countriesCount === 0;
}); // name = SymbolProfileOverrides.name ?? name;
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => { // if (
return sectorsCount === 0; // (SymbolProfileOverrides.sectors as unknown as Sector[])
}); // ?.length > 0
} // ) {
// sectorsCount = (
count = marketData.length; // SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
} // ).length;
// }
return { // }
count,
marketData // return {
}; // assetClass,
} finally { // assetSubClass,
await extendedPrismaClient.$disconnect(); // comment,
// currency,
Logger.debug('Disconnect extended prisma client', 'AdminService'); // countriesCount,
} // dataSource,
// id,
// isActive,
// lastMarketPrice,
// name,
// symbol,
// marketDataItemCount,
// sectorsCount,
// activitiesCount: _count.Order,
// date: Order?.[0]?.date,
// isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
// };
// }
// )
// );
// 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
// };
// } finally {
// await extendedPrismaClient.$disconnect();
// Logger.debug('Disconnect extended prisma client', 'AdminService');
// }
} }
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
@ -629,138 +637,138 @@ export class AdminService {
}); });
} }
private getExtendedPrismaClient() { // private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService'); // Logger.debug('Connect extended prisma client', 'AdminService');
const symbolProfileExtension = Prisma.defineExtension((client) => { // const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({ // return client.$extends({
result: { // result: {
symbolProfile: { // symbolProfile: {
isUsedByUsersWithSubscription: { // isUsedByUsersWithSubscription: {
compute: async ({ id }) => { // compute: async ({ id }) => {
const { _count } = // const { _count } =
await this.prismaService.symbolProfile.findUnique({ // await this.prismaService.symbolProfile.findUnique({
select: { // select: {
_count: { // _count: {
select: { // select: {
Order: { // Order: {
where: { // where: {
User: { // User: {
Subscription: { // Subscription: {
some: { // some: {
expiresAt: { // expiresAt: {
gt: new Date() // gt: new Date()
} // }
} // }
} // }
} // }
} // }
} // }
} // }
} // }
}, // },
where: { // where: {
id // id
} // }
}); // });
return _count.Order > 0; // return _count.Order > 0;
} // }
} // }
} // }
} // }
}); // });
}); // });
return new PrismaClient().$extends(symbolProfileExtension); // return new PrismaClient().$extends(symbolProfileExtension);
} // }
private async getMarketDataForCurrencies(): Promise<AdminMarketData> { // private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); // const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([ // const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({ // this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'], // distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' }, // orderBy: { date: 'desc' },
select: { // select: {
dataSource: true, // dataSource: true,
marketPrice: true, // marketPrice: true,
symbol: true // symbol: true
}, // },
where: { // where: {
dataSource: { // dataSource: {
in: currencyPairs.map(({ dataSource }) => { // in: currencyPairs.map(({ dataSource }) => {
return dataSource; // return dataSource;
}) // })
}, // },
symbol: { // symbol: {
in: currencyPairs.map(({ symbol }) => { // in: currencyPairs.map(({ symbol }) => {
return symbol; // return symbol;
}) // })
} // }
} // }
}), // }),
this.prismaService.marketData.groupBy({ // this.prismaService.marketData.groupBy({
_count: true, // _count: true,
by: ['dataSource', 'symbol'] // by: ['dataSource', 'symbol']
}) // })
]); // ]);
const lastMarketPriceMap = new Map<string, number>(); // const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { // for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set( // lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }), // getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice // marketPrice
); // );
} // }
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map( // const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => { // async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; // let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-'; // let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; // let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) { // if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol); // currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = // ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); // await this.orderService.getStatisticsByCurrency(currency));
} // }
const lastMarketPrice = lastMarketPriceMap.get( // const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol }) // getAssetProfileIdentifier({ dataSource, symbol })
); // );
const marketDataItemCount = // const marketDataItemCount =
marketDataItems.find((marketDataItem) => { // marketDataItems.find((marketDataItem) => {
return ( // return (
marketDataItem.dataSource === dataSource && // marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol // marketDataItem.symbol === symbol
); // );
})?._count ?? 0; // })?._count ?? 0;
return { // return {
activitiesCount, // activitiesCount,
currency, // currency,
dataSource, // dataSource,
lastMarketPrice, // lastMarketPrice,
marketDataItemCount, // marketDataItemCount,
symbol, // symbol,
assetClass: AssetClass.LIQUIDITY, // assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH, // assetSubClass: AssetSubClass.CASH,
countriesCount: 0, // countriesCount: 0,
date: dateOfFirstActivity, // date: dateOfFirstActivity,
id: undefined, // id: undefined,
isActive: true, // isActive: true,
name: symbol, // name: symbol,
sectorsCount: 0 // sectorsCount: 0
}; // };
} // }
); // );
const marketData = await Promise.all(marketDataPromise); // const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length }; // return { marketData, count: marketData.length };
} // }
private async getUsersWithAnalytics({ private async getUsersWithAnalytics({
skip, skip,

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.151.0", "version": "2.152.0-beta.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.151.0", "version": "2.152.0-beta.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.151.0", "version": "2.152.0-beta.3",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save