diff --git a/CHANGELOG.md b/CHANGELOG.md index 635547b11..87b99b790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Extended the _Trackinsight_ data enhancer for asset profile data by `cusip` - Added _Storybook_ to the build process +## 2.141.0 - 2025-02-25 + +### Added + +- Extended the export functionality by the tags +- Extended the portfolio snapshot in the portfolio calculator by the activities count +- Extended the user endpoint `GET api/v1/user` by the activities count +- Added `cusip` to the asset profile model + +### Changed + +- Upgraded `prettier` from version `3.4.2` to `3.5.1` + +### Fixed + +- Improved the numeric comparison of strings in the value component + +## 2.140.0 - 2025-02-20 + ### Changed - Reloaded the available tags after creating a custom tag in the holding detail dialog (experimental) +- Improved the validation of the currency management in the admin control panel - Migrated the `@ghostfolio/client` components to control flow - Migrated the `@ghostfolio/ui` components to control flow +- Improved the language localization for German (`de`) ### Fixed +- Improved the error handling in the `HttpResponseInterceptor` +- Fixed an issue while using symbol profile overrides in the historical market data table of the admin control panel - Added missing assets in _Storybook_ setup ## 2.139.1 - 2025-02-15 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..528fecd44 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps. + +To help us resolve the issue, please include the following details: + +- A description of the vulnerability +- Steps to reproduce the vulnerability +- Affected versions of the software + +We appreciate your responsible disclosure and will work to address the issue promptly. diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 142109725..ee79059f9 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -30,6 +30,7 @@ import { EnhancedSymbolProfile, Filter } from '@ghostfolio/common/interfaces'; +import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { MarketDataPreset } from '@ghostfolio/common/types'; import { BadRequestException, Injectable, Logger } from '@nestjs/common'; @@ -259,7 +260,8 @@ export class AdminService { }, scraperConfiguration: true, sectors: true, - symbol: true + symbol: true, + SymbolProfileOverrides: true } }), this.prismaService.symbolProfile.count({ where }) @@ -313,11 +315,10 @@ export class AdminService { name, Order, sectors, - symbol + symbol, + SymbolProfileOverrides }) => { - const countriesCount = countries - ? Object.keys(countries).length - : 0; + let countriesCount = countries ? Object.keys(countries).length : 0; const lastMarketPrice = lastMarketPriceMap.get( getAssetProfileIdentifier({ dataSource, symbol }) @@ -331,7 +332,34 @@ export class AdminService { ); })?._count ?? 0; - const sectorsCount = sectors ? Object.keys(sectors).length : 0; + let sectorsCount = sectors ? Object.keys(sectors).length : 0; + + if (SymbolProfileOverrides) { + assetClass = SymbolProfileOverrides.assetClass ?? assetClass; + assetSubClass = + SymbolProfileOverrides.assetSubClass ?? assetSubClass; + + if ( + ( + SymbolProfileOverrides.countries as unknown as Prisma.JsonArray + )?.length > 0 + ) { + countriesCount = ( + SymbolProfileOverrides.countries as unknown as Prisma.JsonArray + ).length; + } + + name = SymbolProfileOverrides.name ?? name; + + if ( + (SymbolProfileOverrides.sectors as unknown as Sector[]) + ?.length > 0 + ) { + sectorsCount = ( + SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray + ).length; + } + } return { assetClass, diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index b4aef807a..933e70e9d 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -1,6 +1,7 @@ import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { RequestWithUser } from '@ghostfolio/common/types'; @@ -42,7 +43,7 @@ export class MarketDataController { { dataSource, symbol } ]); - if (!assetProfile) { + if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { throw new HttpException( getReasonPhrase(StatusCodes.NOT_FOUND), StatusCodes.NOT_FOUND @@ -55,7 +56,7 @@ export class MarketDataController { ); const canReadOwnAssetProfile = - assetProfile.userId === this.request.user.id && + assetProfile?.userId === this.request.user.id && hasPermission( this.request.user.permissions, permissions.readMarketDataOfOwnAssetProfile diff --git a/apps/api/src/app/export/export.module.ts b/apps/api/src/app/export/export.module.ts index 048c60359..892a761cc 100644 --- a/apps/api/src/app/export/export.module.ts +++ b/apps/api/src/app/export/export.module.ts @@ -1,6 +1,7 @@ import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; @@ -8,7 +9,7 @@ import { ExportController } from './export.controller'; import { ExportService } from './export.service'; @Module({ - imports: [AccountModule, ApiModule, OrderModule], + imports: [AccountModule, ApiModule, OrderModule, TagModule], controllers: [ExportController], providers: [ExportService] }) diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 1ff18ce9c..219ffffda 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -1,6 +1,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { environment } from '@ghostfolio/api/environments/environment'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { Filter, Export } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @@ -9,7 +10,8 @@ import { Injectable } from '@nestjs/common'; export class ExportService { public constructor( private readonly accountService: AccountService, - private readonly orderService: OrderService + private readonly orderService: OrderService, + private readonly tagService: TagService ) {} public async export({ @@ -60,9 +62,21 @@ export class ExportService { }); } + const tags = (await this.tagService.getTagsForUser(userId)) + .filter(({ isUsed }) => { + return isUsed; + }) + .map(({ id, name }) => { + return { + id, + name + }; + }); + return { meta: { date: new Date().toISOString(), version: environment.version }, accounts, + tags, activities: activities.map( ({ accountId, @@ -72,6 +86,7 @@ export class ExportService { id, quantity, SymbolProfile, + tags: currentTags, type, unitPrice }) => { @@ -86,13 +101,12 @@ export class ExportService { currency: SymbolProfile.currency, dataSource: SymbolProfile.dataSource, date: date.toISOString(), - symbol: - type === 'FEE' || - type === 'INTEREST' || - type === 'ITEM' || - type === 'LIABILITY' - ? SymbolProfile.name - : SymbolProfile.symbol + symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type) + ? SymbolProfile.name + : SymbolProfile.symbol, + tags: currentTags.map(({ id: tagId }) => { + return tagId; + }) }; } ), diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index eb1b841c4..698d13e2b 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -293,6 +293,7 @@ export class ImportService { assetSubClass, countries, createdAt, + cusip, dataSource, figi, figiComposite, @@ -367,6 +368,7 @@ export class ImportService { assetSubClass, countries, createdAt, + cusip, dataSource, figi, figiComposite, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 2f8a9f0c9..e9c4fdcce 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -175,6 +175,7 @@ export abstract class PortfolioCalculator { if (!transactionPoints.length) { return { + activitiesCount: 0, currentValueInBaseCurrency: new Big(0), errors: [], hasErrors: false, diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index cf808debb..209a0efd5 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -101,6 +101,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { totalInterestWithCurrencyEffect, totalInvestment, totalInvestmentWithCurrencyEffect, + activitiesCount: this.activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, errors: [], historicalData: [], totalLiabilitiesWithCurrencyEffect: new Big(0), diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index dcf9d9404..40bc1b2b5 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -86,6 +86,9 @@ export class UserService { orderBy: { alias: 'asc' }, where: { GranteeUser: { id } } }), + this.prismaService.order.count({ + where: { userId: id } + }), this.prismaService.order.findFirst({ orderBy: { date: 'asc' @@ -96,8 +99,9 @@ export class UserService { ]); const access = userData[0]; - const firstActivity = userData[1]; - let tags = userData[2]; + const activitiesCount = userData[1]; + const firstActivity = userData[2]; + let tags = userData[3]; let systemMessage: SystemMessage; @@ -117,6 +121,7 @@ export class UserService { } return { + activitiesCount, id, permissions, subscription, diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 2b8eaf2e9..32b494085 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -71,7 +71,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return {}; }); - const isin = profile?.isin?.split(';')?.[0]; + const cusip = profile?.cusip; + + if (cusip) { + response.cusip = cusip; + } + + const isin = profile?.isins?.[0]; if (isin) { response.isin = isin; diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index b79b2a098..b3e7de0b3 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -200,6 +200,7 @@ export class DataGatheringService { assetSubClass, countries, currency, + cusip, dataSource, figi, figiComposite, @@ -218,6 +219,7 @@ export class DataGatheringService { assetSubClass, countries, currency, + cusip, dataSource, figi, figiComposite, @@ -234,6 +236,7 @@ export class DataGatheringService { assetSubClass, countries, currency, + cusip, figi, figiComposite, figiShareClass, 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 df0526d9f..0dae63311 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -204,8 +204,7 @@ export class SymbolProfileService { ?.length > 0 ) { item.countries = this.getCountries( - item.SymbolProfileOverrides - ?.countries as unknown as Prisma.JsonArray + item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray ); } @@ -214,22 +213,22 @@ export class SymbolProfileService { ?.length > 0 ) { item.holdings = this.getHoldings( - item.SymbolProfileOverrides?.holdings as unknown as Prisma.JsonArray + item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray ); } - item.name = item.SymbolProfileOverrides?.name ?? item.name; + item.name = item.SymbolProfileOverrides.name ?? item.name; if ( (item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length > 0 ) { item.sectors = this.getSectors( - item.SymbolProfileOverrides?.sectors as unknown as Prisma.JsonArray + item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray ); } - item.url = item.SymbolProfileOverrides?.url ?? item.url; + item.url = item.SymbolProfileOverrides.url ?? item.url; delete item.SymbolProfileOverrides; } diff --git a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts index fa5e33f10..b0f69fa5c 100644 --- a/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts @@ -15,9 +15,11 @@ import { FormControl, FormGroup, ValidationErrors, + ValidatorFn, Validators } from '@angular/forms'; import { MatDialogRef } from '@angular/material/dialog'; +import { isISO4217CurrencyCode } from 'class-validator'; import { uniq } from 'lodash'; import { Subject, takeUntil } from 'rxjs'; @@ -52,9 +54,7 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy { this.createAssetProfileForm = this.formBuilder.group( { addCurrency: new FormControl(null, [ - Validators.maxLength(3), - Validators.minLength(3), - Validators.required + this.iso4217CurrencyCodeValidator() ]), addSymbol: new FormControl(null, [Validators.required]), searchSymbol: new FormControl(null, [Validators.required]) @@ -83,11 +83,11 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy { symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol }); } else if (this.mode === 'currency') { - const currency = this.createAssetProfileForm - .get('addCurrency') - .value.toUpperCase(); + const currency = ( + this.createAssetProfileForm.get('addCurrency').value as string + ).toUpperCase(); - const currencies = uniq([...this.customCurrencies, currency]); + const currencies = uniq([...this.customCurrencies, currency]).sort(); this.dataService .putAdminSetting(PROPERTY_CURRENCIES, { @@ -109,10 +109,7 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy { const addCurrencyFormControl = this.createAssetProfileForm.get('addCurrency'); - if ( - addCurrencyFormControl.hasError('maxlength') || - addCurrencyFormControl.hasError('minlength') - ) { + if (addCurrencyFormControl.hasError('invalidCurrency')) { return true; } @@ -161,4 +158,14 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy { this.changeDetectorRef.markForCheck(); }); } + + private iso4217CurrencyCodeValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!isISO4217CurrencyCode(control.value?.toUpperCase())) { + return { invalidCurrency: true }; + } + + return null; + }; + } } diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index d217f871d..f54af4174 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -28,7 +28,6 @@ import { formatDistanceToNowStrict, parseISO } from 'date-fns'; -import { uniq } from 'lodash'; import { StringValue } from 'ms'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -122,24 +121,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons }); } - public onAddCurrency() { - const currency = prompt($localize`Please add a currency:`); - - if (currency) { - if (currency.length === 3) { - const currencies = uniq([ - ...this.customCurrencies, - currency.toUpperCase() - ]); - this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies }); - } else { - this.notificationService.alert({ - title: $localize`${currency} is an invalid currency!` - }); - } - } - } - public onChangeCouponDuration(aCouponDuration: StringValue) { this.couponDuration = aCouponDuration; } diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index ba8545d16..a85c32d43 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -95,16 +95,6 @@ } -
EUR
for Euro