Browse Source

Merge branch 'main' into task/zh-localization

pull/7490/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
2ff6fe3333
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      CHANGELOG.md
  2. 7
      apps/api/src/app/admin/admin.service.ts
  3. 7
      apps/api/src/app/auth/api-key.strategy.ts
  4. 7
      apps/api/src/app/auth/jwt.strategy.ts
  5. 4
      apps/api/src/app/health/health.service.ts
  6. 1
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  7. 14
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 4
      apps/api/src/app/subscription/subscription.controller.ts
  9. 70
      apps/api/src/app/user/user.service.ts
  10. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  11. 70
      apps/api/src/services/property/property.service.ts
  12. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  13. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  14. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  15. 11
      apps/client/src/styles.scss
  16. 3
      libs/common/src/lib/config.ts
  17. 11
      libs/ui/src/lib/activities-table/activities-table.component.html
  18. 12
      libs/ui/src/lib/activities-table/activities-table.component.ts

20
CHANGELOG.md

@ -9,11 +9,31 @@ 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
- 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 Chinese (`zh`)
- 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

7
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<string[]>(PROPERTY_CURRENCIES);
const customCurrencies = await this.propertyService.getByKey<string[]>(
PROPERTY_CURRENCIES,
{ skipCache: true }
);
if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter(

7
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: {

7
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;

4
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 {

1
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -92,6 +92,7 @@ describe('PortfolioService', () => {
null,
null,
null,
null,
null
);

14
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,8 +1187,7 @@ export class PortfolioService {
id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
rules: hasOpenHoldings
? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
@ -1212,8 +1213,7 @@ export class PortfolioService {
id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
rules: hasOpenHoldings
? await this.rulesService.evaluate(
[
new AssetClassClusterRiskEquity(
@ -1266,8 +1266,7 @@ export class PortfolioService {
id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
rules: hasOpenHoldings
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
@ -1295,8 +1294,7 @@ export class PortfolioService {
id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
rules: hasOpenHoldings
? await this.rulesService.evaluate(
[
new RegionalMarketClusterRiskAsiaPacific(

4
apps/api/src/app/subscription/subscription.controller.ts

@ -54,7 +54,9 @@ export class SubscriptionController {
}
let coupons =
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? [];
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS, {
skipCache: true
})) ?? [];
const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode;

70
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<UserWithSettings | null> {
@ -782,4 +822,26 @@ export class UserService {
return settings;
}
private async getMaxDailyRequests() {
const value = await this.propertyService.getByKey<string>(
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;
}
}

6
apps/api/src/services/benchmark/benchmark.service.ts

@ -159,7 +159,8 @@ export class BenchmarkService {
let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id });
@ -196,7 +197,8 @@ export class BenchmarkService {
let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => {

70
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<Property[]>;
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<TValue extends PropertyValue>(aKey: PropertyKey) {
const properties = await this.get();
public async getByKey<TValue extends PropertyValue>(
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;
}
}

1
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -33,6 +33,7 @@
<mat-tab-group
animationDuration="0ms"
class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false"
>
<mat-tab>

1
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -105,6 +105,7 @@
<mat-tab-group
animationDuration="0ms"
[disablePagination]="true"
[dynamicHeight]="true"
[mat-stretch-tabs]="false"
>

1
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -39,6 +39,7 @@
<mat-tab-group
animationDuration="0ms"
class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false"
>
<mat-tab>

11
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,

3
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;

11
libs/ui/src/lib/activities-table/activities-table.component.html

@ -84,14 +84,19 @@
<button
class="align-items-center d-flex"
mat-menu-item
[disabled]="
dataSource()?.data.length === 0 || !hasPermissionToDeleteActivity
"
[disabled]="!canDeleteActivities()"
(click)="onDeleteActivities()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
@if (canDeleteActivities()) {
<span i18n
>Delete {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Delete Activities</span>
}
</span>
</button>
</mat-menu>

12
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?`
});
}

Loading…
Cancel
Save