From df2e96fc6f8447c6e0dc9081e01ee8edb44dba67 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:20:57 +0200 Subject: [PATCH 1/5] Task/improve performance of property service by caching properties (#7484) * Improve performance by caching properties in memory * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/admin/admin.service.ts | 7 +- apps/api/src/app/health/health.service.ts | 4 +- .../subscription/subscription.controller.ts | 4 +- .../services/benchmark/benchmark.service.ts | 6 +- .../src/services/property/property.service.ts | 70 +++++++++++++++++-- 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 477048f594..d5c6481e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the style of the empty state in the _Fear & Greed Index_ component - Improved the style of the type filter in the activities table component (experimental) +- Improved the performance of the property service by caching the properties in memory - Improved the language localization for German (`de`) ## 3.37.0 - 2026-07-30 diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 26a4e06f47..4c608e0fd2 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -107,8 +107,11 @@ export class AdminService { await this.marketDataService.deleteMany({ dataSource, symbol }); const currency = getCurrencyFromSymbol(symbol); - const customCurrencies = - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + const customCurrencies = await this.propertyService.getByKey( + PROPERTY_CURRENCIES, + { skipCache: true } + ); if (customCurrencies.includes(currency)) { const updatedCustomCurrencies = customCurrencies.filter( diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts index f08f33a1e3..42a0be61b8 100644 --- a/apps/api/src/app/health/health.service.ts +++ b/apps/api/src/app/health/health.service.ts @@ -26,7 +26,9 @@ export class HealthService { public async isDatabaseHealthy() { try { - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + await this.propertyService.getByKey(PROPERTY_CURRENCIES, { + skipCache: true + }); return true; } catch { diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 4018e4753e..a70fe87916 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -54,7 +54,9 @@ export class SubscriptionController { } let coupons = - (await this.propertyService.getByKey(PROPERTY_COUPONS)) ?? []; + (await this.propertyService.getByKey(PROPERTY_COUPONS, { + skipCache: true + })) ?? []; const coupon = coupons.find((currentCoupon) => { return currentCoupon.code === couponCode; diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 17e729f9f4..993e0f0aae 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -159,7 +159,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks.push({ symbolProfileId: assetProfile.id }); @@ -196,7 +197,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks = benchmarks.filter(({ symbolProfileId }) => { diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts index 80643482fa..6d8130bbc2 100644 --- a/apps/api/src/services/property/property.service.ts +++ b/apps/api/src/services/property/property.service.ts @@ -6,27 +6,39 @@ import { import { PropertyKey } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { Property } from '@prisma/client'; +import { addMilliseconds, isBefore } from 'date-fns'; +import ms from 'ms'; import { PropertyValue } from './interfaces/interfaces'; @Injectable() export class PropertyService { + private static readonly CACHE_TTL = ms('1 minute'); + + private cachedProperties: Promise; + private cachedPropertiesExpiresAt: Date; + public constructor(private readonly prismaService: PrismaService) {} public async delete({ key }: { key: PropertyKey }) { - return this.prismaService.property.delete({ + const property = await this.prismaService.property.delete({ where: { key } }); + + this.invalidateCache(); + + return property; } - public async get() { + public async get({ skipCache = false } = {}) { const response: { [key: string]: PropertyValue; } = { [PROPERTY_CURRENCIES]: [] }; - const properties = await this.prismaService.property.findMany(); + const properties = await this.getProperties({ skipCache }); for (const property of properties) { let value = property.value; @@ -41,8 +53,11 @@ export class PropertyService { return response; } - public async getByKey(aKey: PropertyKey) { - const properties = await this.get(); + public async getByKey( + aKey: PropertyKey, + { skipCache = false } = {} + ) { + const properties = await this.get({ skipCache }); return properties[aKey] as TValue; } @@ -53,10 +68,53 @@ export class PropertyService { } public async put({ key, value }: { key: PropertyKey; value: string }) { - return this.prismaService.property.upsert({ + const property = await this.prismaService.property.upsert({ create: { key, value }, update: { value }, where: { key } }); + + this.invalidateCache(); + + return property; + } + + /** + * Returns the properties from the in-memory cache, falling back to the + * database. Callers which write back a modified property must set + * skipCache to avoid basing the write on a stale read. + */ + private async getProperties({ skipCache = false } = {}) { + if (skipCache) { + return this.prismaService.property.findMany(); + } + + if ( + this.cachedProperties && + isBefore(new Date(), this.cachedPropertiesExpiresAt) + ) { + return this.cachedProperties; + } + + const properties = this.prismaService.property.findMany().catch((error) => { + if (this.cachedProperties === properties) { + this.invalidateCache(); + } + + throw error; + }); + + this.cachedProperties = properties; + this.cachedPropertiesExpiresAt = addMilliseconds( + new Date(), + PropertyService.CACHE_TTL + ); + + return this.cachedProperties; + } + + private invalidateCache() { + this.cachedProperties = undefined; + this.cachedPropertiesExpiresAt = undefined; } } From 1770350516c05dddf817f6d727dcdb409f8a873f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:21:48 +0200 Subject: [PATCH 2/5] Bugfix/static portfolio analysis rules for portfolio with no holdings (#7466) * Fix static portfolio analysis rules for portfolio with no holdings * Update changelog --- CHANGELOG.md | 14 ++ .../src/app/portfolio/portfolio.service.ts | 208 +++++++++--------- 2 files changed, 117 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c6481e3a..d14f77f5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the performance of the property service by caching the properties in memory - Improved the language localization for German (`de`) +### Fixed + +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment: Base Currency) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Developed Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Emerging Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Asia-Pacific) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Emerging Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Europe) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Japan) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (North America) + ## 3.37.0 - 2026-07-30 ### Added diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6a3c1f145a..d1aff38111 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1126,6 +1126,8 @@ export class PortfolioService { withSummary: true }); + const hasOpenHoldings = Object.keys(holdings).length > 0; + const marketsAdvancedTotalInBaseCurrency = getSum( Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { return new Big(valueInBaseCurrency); @@ -1185,26 +1187,25 @@ export class PortfolioService { id: 'rule.currencyClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( - this.exchangeRateDataService, - this.i18nService, - Object.values(holdings), - userSettings.language - ), - new CurrencyClusterRiskCurrentInvestment( - this.exchangeRateDataService, - this.i18nService, - Object.values(holdings), - userSettings.language - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ) + ], + userSettings + ) + : undefined }, { key: 'assetClassClusterRisk', @@ -1212,26 +1213,25 @@ export class PortfolioService { id: 'rule.assetClassClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new AssetClassClusterRiskEquity( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - Object.values(holdings) - ), - new AssetClassClusterRiskFixedIncome( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - Object.values(holdings) - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new AssetClassClusterRiskEquity( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ), + new AssetClassClusterRiskFixedIncome( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ) + ], + userSettings + ) + : undefined }, { key: 'accountClusterRisk', @@ -1266,28 +1266,27 @@ export class PortfolioService { id: 'rule.economicMarketClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new EconomicMarketClusterRiskDevelopedMarkets( - this.exchangeRateDataService, - this.i18nService, - marketsTotalInBaseCurrency, - markets.developedMarkets.valueInBaseCurrency, - userSettings.language - ), - new EconomicMarketClusterRiskEmergingMarkets( - this.exchangeRateDataService, - this.i18nService, - marketsTotalInBaseCurrency, - markets.emergingMarkets.valueInBaseCurrency, - userSettings.language - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new EconomicMarketClusterRiskDevelopedMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.developedMarkets.valueInBaseCurrency, + userSettings.language + ), + new EconomicMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.emergingMarkets.valueInBaseCurrency, + userSettings.language + ) + ], + userSettings + ) + : undefined }, { key: 'regionalMarketClusterRisk', @@ -1295,49 +1294,48 @@ export class PortfolioService { id: 'rule.regionalMarketClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new RegionalMarketClusterRiskAsiaPacific( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.asiaPacific.valueInBaseCurrency - ), - new RegionalMarketClusterRiskEmergingMarkets( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.emergingMarkets.valueInBaseCurrency - ), - new RegionalMarketClusterRiskEurope( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.europe.valueInBaseCurrency - ), - new RegionalMarketClusterRiskJapan( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.japan.valueInBaseCurrency - ), - new RegionalMarketClusterRiskNorthAmerica( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.northAmerica.valueInBaseCurrency - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new RegionalMarketClusterRiskAsiaPacific( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.asiaPacific.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.emergingMarkets.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEurope( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.europe.valueInBaseCurrency + ), + new RegionalMarketClusterRiskJapan( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.japan.valueInBaseCurrency + ), + new RegionalMarketClusterRiskNorthAmerica( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.northAmerica.valueInBaseCurrency + ) + ], + userSettings + ) + : undefined }, { key: 'fees', From bf923f1afe27705c2f758a68445ea81d127a4be5 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:22:56 +0200 Subject: [PATCH 3/5] Task/improve style of tabs in various dialogs on mobile (#7481) * Improve tabs style * Update changelog --- CHANGELOG.md | 3 +++ .../account-detail-dialog/account-detail-dialog.html | 1 + .../asset-profile-dialog/asset-profile-dialog.html | 1 + .../holding-detail-dialog/holding-detail-dialog.html | 1 + apps/client/src/styles.scss | 11 +++++++++++ 5 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14f77f5e7..62cbee836d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the style of the tabs in the account detail dialog on mobile +- Improved the style of the tabs in the holding detail dialog on mobile +- Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile - Improved the style of the empty state in the _Fear & Greed Index_ component - Improved the style of the type filter in the activities table component (experimental) - Improved the performance of the property service by caching the properties in memory diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 32a11717f8..ff39f23b3e 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -33,6 +33,7 @@ diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index c9abdeeb7c..f0b914b1cc 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -105,6 +105,7 @@ diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 464f0b6b2b..c4f53497f3 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -39,6 +39,7 @@ diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 6ef55cb322..739af30ae0 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -411,6 +411,17 @@ ngx-skeleton-loader { .mdc-dialog__content { --mat-dialog-supporting-text-color: rgba(var(--dark-primary-text)); } + + @media (max-width: 575.98px) { + // Tabs fill the available width on mobile + .mat-mdc-tab-group { + .mat-mdc-tab { + flex-grow: 1; + min-width: unset; + padding: 0 0.5rem; + } + } + } } .mat-mdc-fab, From eb62190c74b47b731702add1eae1abba7983b5dc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:32:28 +0200 Subject: [PATCH 4/5] Task/add activity count to delete menu item and confirmation dialog of activities table component (#7489) * Add activity count to delete menu and confirmation dialog * Update changelog --- CHANGELOG.md | 2 ++ .../activities-table.component.html | 13 +++++++++---- .../activities-table/activities-table.component.ts | 12 +++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62cbee836d..421a1bd6c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the style of the tabs in the holding detail dialog on mobile - Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile - Improved the style of the empty state in the _Fear & Greed Index_ component +- Added the activity count to the delete menu item of the activities table +- Added the activity count to the deletion confirmation dialog of the activities table - Improved the style of the type filter in the activities table component (experimental) - Improved the performance of the property service by caching the properties in memory - Improved the language localization for German (`de`) diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 3d91852a0c..1ff8496a9b 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -84,14 +84,19 @@ diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 573c8c93a4..a4e7403a7b 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -282,6 +282,13 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit { ); } + public canDeleteActivities() { + return ( + (this.dataSource()?.data.length ?? 0) > 0 && + this.hasPermissionToDeleteActivity + ); + } + public isExcludedFromAnalysis(activity: Activity) { return ( (activity.account && isAccountExcluded(activity.account)) ?? @@ -314,7 +321,10 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit { this.activitiesDeleted.emit(); }, confirmType: ConfirmationDialogType.Warn, - title: $localize`Do you really want to delete these activities?` + title: + this.totalItems === 1 + ? $localize`Do you really want to delete this activity?` + : $localize`Do you really want to delete these ${this.totalItems}:count: activities?` }); } From 81e4abbfeb8affcfbf25d189ef4ace300011685b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:41:24 +0200 Subject: [PATCH 5/5] Feature/add daily request limit (#7487) Add daily request limit --- apps/api/src/app/auth/api-key.strategy.ts | 7 ++ apps/api/src/app/auth/jwt.strategy.ts | 7 ++ .../app/portfolio/portfolio.service.spec.ts | 1 + apps/api/src/app/user/user.service.ts | 70 +++++++++++++++++-- libs/common/src/lib/config.ts | 3 + 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts index f9937aaa7a..232a272bcd 100644 --- a/apps/api/src/app/auth/api-key.strategy.ts +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy( ); } + if (await this.userService.isDailyRequestLimitExceeded({ user })) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + await this.prismaService.analytics.upsert({ create: { user: { connect: { id: user.id } } }, update: { diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts index c70e8fb60c..189389a860 100644 --- a/apps/api/src/app/auth/jwt.strategy.ts +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ); } + if (await this.userService.isDailyRequestLimitExceeded({ user })) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + const country = countriesAndTimezones.getCountryForTimezone(timezone)?.id; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c16590cceb..d85258f7ef 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -92,6 +92,7 @@ describe('PortfolioService', () => { null, null, null, + null, null ); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 7f3631c54d..febc967d23 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -31,9 +31,12 @@ import { DEFAULT_LOCALE, PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, - TAG_ID_EXCLUDE_FROM_ANALYSIS + TAG_ID_EXCLUDE_FROM_ANALYSIS, + THROTTLE_DAILY_KEY, + THROTTLE_DAILY_TTL } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { @@ -50,15 +53,18 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; import { Prisma, Role, Settings, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; -import { without } from 'lodash'; +import { isNil, without } from 'lodash'; import { createHmac } from 'node:crypto'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + public constructor( private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, @@ -67,7 +73,9 @@ export class UserService { private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, - private readonly tagService: TagService + private readonly tagService: TagService, + @InjectThrottlerStorage() + private readonly throttlerStorage: ThrottlerStorage ) {} public async count(args?: Prisma.UserCountArgs) { @@ -228,6 +236,38 @@ export class UserService { return usersWithAdminRole.length > 0; } + public async isDailyRequestLimitExceeded({ + user + }: { + user: UserWithSettings; + }) { + if (user.subscription?.type === SubscriptionType.Premium) { + return false; + } + + const maxDailyRequests = await this.getMaxDailyRequests(); + + if (maxDailyRequests === undefined) { + return false; + } + + try { + const { isBlocked } = await this.throttlerStorage.increment( + `${THROTTLE_DAILY_KEY}-${user.id}`, + THROTTLE_DAILY_TTL, + maxDailyRequests, + THROTTLE_DAILY_TTL, + THROTTLE_DAILY_KEY + ); + + return isBlocked; + } catch (error) { + this.logger.error(error); + + return false; + } + } + public async user( userWhereUniqueInput: Prisma.UserWhereUniqueInput ): Promise { @@ -782,4 +822,26 @@ export class UserService { return settings; } + + private async getMaxDailyRequests() { + const value = await this.propertyService.getByKey( + PROPERTY_MAX_DAILY_REQUESTS + ); + + if (isNil(value) || value === '') { + return undefined; + } + + const maxDailyRequests = Number(value); + + if (!Number.isInteger(maxDailyRequests) || maxDailyRequests < 0) { + this.logger.warn( + `The property ${PROPERTY_MAX_DAILY_REQUESTS} is not a non-negative integer ("${value}"), the daily request limit is not applied` + ); + + return undefined; + } + + return maxDailyRequests; + } } diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 6b070755f2..890c29fbb3 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -253,6 +253,7 @@ export const PROPERTY_DEMO_USER_ID = 'DEMO_USER_ID'; export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; +export const PROPERTY_MAX_DAILY_REQUESTS = 'MAX_DAILY_REQUESTS'; export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH'; export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES'; @@ -326,6 +327,8 @@ export const TAG_ID_EXCLUDE_FROM_ANALYSIS = 'f2e868af-8333-459f-b161-cbc6544c24bd'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; +export const THROTTLE_DAILY_KEY = 'daily'; +export const THROTTLE_DAILY_TTL = ms('1 day'); export const THROTTLE_DEFAULT_LIMIT = 10; export const THROTTLE_DEFAULT_TTL = ms('1 minute'); export const THROTTLE_SIGNUP_LIMIT = 5;