diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de7035bb..bed509d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added _selfh.st_ to the _As seen in_ section on the landing page + ### Changed +- Optimized the 7d data gathering by prioritizing the currencies +- Improved the language localization for German (`de`) - Upgraded `Node.js` from version `18` to `20` (`Dockerfile`) +- Upgraded `Nx` from version `19.4.0` to `19.4.3` + +### Fixed + +- Fixed the table sorting of the holdings tab on the home page ## 2.96.0 - 2024-07-13 diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 4494fef7a..69e6955c1 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -81,10 +81,11 @@ export class AdminController { @Post('gather/max') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherMax(): Promise { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, @@ -107,10 +108,11 @@ export class AdminController { @Post('gather/profile-data') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherProfileData(): Promise { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 3d81435ab..b15c3efc3 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -27,12 +27,13 @@ import { } from '@ghostfolio/common/interfaces'; import { MarketDataPreset } from '@ghostfolio/common/types'; -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { AssetClass, AssetSubClass, DataSource, Prisma, + PrismaClient, Property, SymbolProfile } from '@prisma/client'; @@ -212,98 +213,113 @@ export class AdminService { } } - let [assetProfiles, count] = await Promise.all([ - this.prismaService.symbolProfile.findMany({ - orderBy, - skip, - take, - where, - select: { - _count: { - select: { Order: true } - }, - assetClass: true, - assetSubClass: true, - comment: true, - countries: true, - currency: true, - dataSource: true, - id: true, - name: true, - Order: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 - }, - scraperConfiguration: true, - sectors: true, - symbol: true - } - }), - this.prismaService.symbolProfile.count({ where }) - ]); + const extendedPrismaClient = this.getExtendedPrismaClient(); - let marketData: AdminMarketDataItem[] = assetProfiles.map( - ({ - _count, - assetClass, - assetSubClass, - comment, - countries, - currency, - dataSource, - id, - name, - Order, - sectors, - symbol - }) => { - const countriesCount = countries ? Object.keys(countries).length : 0; - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - const sectorsCount = sectors ? Object.keys(sectors).length : 0; + try { + let [assetProfiles, count] = await Promise.all([ + extendedPrismaClient.symbolProfile.findMany({ + orderBy, + skip, + take, + where, + select: { + _count: { + select: { Order: true } + }, + assetClass: true, + assetSubClass: true, + comment: true, + countries: true, + currency: true, + dataSource: true, + id: true, + isUsedByUsersWithSubscription: true, + name: true, + Order: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + scraperConfiguration: true, + sectors: true, + symbol: true + } + }), + this.prismaService.symbolProfile.count({ where }) + ]); - return { - assetClass, - assetSubClass, - comment, - currency, - countriesCount, - dataSource, - id, - name, - symbol, - marketDataItemCount, - sectorsCount, - activitiesCount: _count.Order, - date: Order?.[0]?.date - }; - } - ); + let marketData: AdminMarketDataItem[] = await Promise.all( + assetProfiles.map( + async ({ + _count, + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource, + id, + isUsedByUsersWithSubscription, + name, + Order, + sectors, + symbol + }) => { + const countriesCount = countries + ? Object.keys(countries).length + : 0; + 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, + currency, + countriesCount, + dataSource, + id, + 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; - }); + 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; } - count = marketData.length; - } + return { + count, + marketData + }; + } finally { + await extendedPrismaClient.$disconnect(); - return { - count, - marketData - }; + Logger.debug('Disconnect extended prisma client', 'AdminService'); + } } public async getMarketDataBySymbol({ @@ -431,6 +447,52 @@ export class AdminService { return response; } + private getExtendedPrismaClient() { + Logger.debug('Connect extended prisma client', 'AdminService'); + + const symbolProfileExtension = Prisma.defineExtension((client) => { + return client.$extends({ + result: { + symbolProfile: { + isUsedByUsersWithSubscription: { + compute: async ({ id }) => { + const { _count } = + await this.prismaService.symbolProfile.findUnique({ + select: { + _count: { + select: { + Order: { + where: { + User: { + Subscription: { + some: { + expiresAt: { + gt: new Date() + } + } + } + } + } + } + } + } + }, + where: { + id + } + }); + + return _count.Order > 0; + } + } + } + } + }); + }); + + return new PrismaClient().$extends(symbolProfileExtension); + } + private async getMarketDataForCurrencies(): Promise { const marketDataItems = await this.prismaService.marketData.groupBy({ _count: true, diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index fc5d613a2..864891c6a 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -45,10 +45,11 @@ export class CronService { @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) public async runEverySundayAtTwelvePm() { if (await this.isDataGatheringEnabled()) { - const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); + const assetProfileIdentifiers = + await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( - uniqueAssets.map(({ dataSource, symbol }) => { + assetProfileIdentifiers.map(({ dataSource, symbol }) => { return { data: { dataSource, diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index a80d68d6b..2bf6cc1b2 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -10,6 +10,7 @@ import { DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_LOW, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, PROPERTY_BENCHMARKS @@ -62,9 +63,22 @@ export class DataGatheringService { } public async gather7Days() { - const dataGatheringItems = await this.getSymbols7D(); await this.gatherSymbols({ - dataGatheringItems, + dataGatheringItems: await this.getCurrencies7D(), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: true + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: false + }), priority: DATA_GATHERING_QUEUE_PRIORITY_LOW }); } @@ -138,7 +152,7 @@ export class DataGatheringService { }); if (!uniqueAssets) { - uniqueAssets = await this.getUniqueAssets(); + uniqueAssets = await this.getAllAssetProfileIdentifiers(); } if (uniqueAssets.length <= 0) { @@ -270,7 +284,7 @@ export class DataGatheringService { ); } - public async getUniqueAssets(): Promise { + public async getAllAssetProfileIdentifiers(): Promise { const symbolProfiles = await this.prismaService.symbolProfile.findMany({ orderBy: [{ symbol: 'asc' }] }); @@ -290,73 +304,83 @@ export class DataGatheringService { }); } - private getEarliestDate(aStartDate: Date) { - return min([aStartDate, subYears(new Date(), 10)]); - } - - private async getSymbols7D(): Promise { - const startDate = subDays(resetHours(new Date()), 7); - - const symbolProfiles = await this.prismaService.symbolProfile.findMany({ - orderBy: [{ symbol: 'asc' }], - select: { - dataSource: true, - scraperConfiguration: true, - symbol: true - } - }); - - // Only consider symbols with incomplete market data for the last - // 7 days - const symbolsWithCompleteMarketData = ( + private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise< + UniqueAsset[] + > { + return ( await this.prismaService.marketData.groupBy({ _count: true, - by: ['symbol'], + by: ['dataSource', 'symbol'], orderBy: [{ symbol: 'asc' }], where: { - date: { gt: startDate }, + date: { gt: subDays(resetHours(new Date()), 7) }, state: 'CLOSE' } }) ) - .filter((group) => { - return group._count >= 6; + .filter(({ _count }) => { + return _count >= 6; }) - .map((group) => { - return group.symbol; + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; }); + } + + private async getCurrencies7D(): Promise { + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); - const symbolProfilesToGather = symbolProfiles + return this.exchangeRateDataService + .getCurrencyPairs() + .filter(({ dataSource, symbol }) => { + return !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }); + }) + .map(({ dataSource, symbol }) => { + return { + dataSource, + symbol, + date: subDays(resetHours(new Date()), 7) + }; + }); + } + + private getEarliestDate(aStartDate: Date) { + return min([aStartDate, subYears(new Date(), 10)]); + } + + private async getSymbols7D({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }): Promise { + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesByUserSubscription({ + withUserSubscription + }); + + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); + + return symbolProfiles .filter(({ dataSource, scraperConfiguration, symbol }) => { const manualDataSourceWithScraperConfiguration = dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); return ( - !symbolsWithCompleteMarketData.includes(symbol) && + !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }) && (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) ); }) .map((symbolProfile) => { return { ...symbolProfile, - date: startDate + date: subDays(resetHours(new Date()), 7) }; }); - - const currencyPairsToGather = this.exchangeRateDataService - .getCurrencyPairs() - .filter(({ symbol }) => { - return !symbolsWithCompleteMarketData.includes(symbol); - }) - .map(({ dataSource, symbol }) => { - return { - dataSource, - symbol, - date: startDate - }; - }); - - return [...currencyPairsToGather, ...symbolProfilesToGather]; } private async getSymbolsMax(): Promise { diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 1d7ea556b..e0cfed292 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -91,6 +91,40 @@ export class SymbolProfileService { }); } + public async getSymbolProfilesByUserSubscription({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }) { + return this.prismaService.symbolProfile.findMany({ + include: { + Order: { + include: { + User: true + } + } + }, + orderBy: [{ symbol: 'asc' }], + where: { + Order: withUserSubscription + ? { + some: { + User: { + Subscription: { some: { expiresAt: { gt: new Date() } } } + } + } + } + : { + every: { + User: { + Subscription: { none: { expiresAt: { gt: new Date() } } } + } + } + } + } + }); + } + public updateSymbolProfile({ assetClass, assetSubClass, 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 5494e6842..e27283517 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 @@ -6,8 +6,14 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { getDateFormatString } from '@ghostfolio/common/helper'; -import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces'; +import { + Filter, + InfoItem, + UniqueAsset, + User +} from '@ghostfolio/common/interfaces'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { translate } from '@ghostfolio/ui/i18n'; import { SelectionModel } from '@angular/cdk/collections'; @@ -97,22 +103,11 @@ export class AdminMarketDataComponent new MatTableDataSource(); public defaultDateFormat: string; public deviceType: string; - public displayedColumns = [ - 'select', - 'nameWithSymbol', - 'dataSource', - 'assetClass', - 'assetSubClass', - 'date', - 'activitiesCount', - 'marketDataItemCount', - 'sectorsCount', - 'countriesCount', - 'comment', - 'actions' - ]; + public displayedColumns: string[] = []; public filters$ = new Subject(); public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; + public hasPermissionForSubscription: boolean; + public info: InfoItem; public isLoading = false; public isUUID = isUUID; public placeholder = ''; @@ -134,6 +129,33 @@ export class AdminMarketDataComponent private router: Router, private userService: UserService ) { + this.info = this.dataService.fetchInfo(); + + this.hasPermissionForSubscription = hasPermission( + this.info?.globalPermissions, + permissions.enableSubscription + ); + + this.displayedColumns = [ + 'select', + 'nameWithSymbol', + 'dataSource', + 'assetClass', + 'assetSubClass', + 'date', + 'activitiesCount', + 'marketDataItemCount', + 'sectorsCount', + 'countriesCount' + ]; + + if (this.hasPermissionForSubscription) { + this.displayedColumns.push('isUsedByUsersWithSubscription'); + } + + this.displayedColumns.push('comment'); + this.displayedColumns.push('actions'); + this.route.queryParams .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((params) => { 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 3dc3dd5a9..f3b2d8ddd 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 @@ -144,6 +144,15 @@ + + + + @if (element.isUsedByUsersWithSubscription) { + + } + + + diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts index 87562460a..224e3506b 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.module.ts @@ -1,5 +1,6 @@ import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; @@ -24,6 +25,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/ GfActivitiesFilterComponent, GfAssetProfileDialogModule, GfCreateAssetProfileDialogModule, + GfPremiumIndicatorComponent, GfSymbolModule, MatButtonModule, MatCheckboxModule, diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index a2ea30a69..b3ebe941c 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -41,7 +41,8 @@ [holdings]="holdings" (treemapChartClicked)="onSymbolClicked($event)" /> - } @else if (viewModeFormControl.value === 'TABLE') { + } +
} - } + diff --git a/apps/client/src/app/pages/landing/landing-page.html b/apps/client/src/app/pages/landing/landing-page.html index 7555f3540..72de38c20 100644 --- a/apps/client/src/app/pages/landing/landing-page.html +++ b/apps/client/src/app/pages/landing/landing-page.html @@ -186,6 +186,14 @@ title="Sackgeld.com – Apps für ein höheres Sackgeld" > +
+ +