diff --git a/CHANGELOG.md b/CHANGELOG.md index 654ffa548..6febc487a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the platform logo to the account selector in the create or update activity dialog + +## 3.41.0 - 2026-08-03 + +### Added + +- Added support for the account platforms in the activities import - Added the database model and endpoints to manage the stock splits of an asset profile (experimental) +### Changed + +- Improved the usability of the admin control panel by eliminating the page reload on changing a setting +- Improved the usability of the admin control panel by eliminating the page reload on deleting an asset profile +- Improved the usability of the admin control panel by eliminating the page reload on flushing the cache +- Improved the usability of the admin control panel by eliminating the page reload on gathering historical market data +- Improved the language localization for German (`de`) + ### Fixed - Fixed the loading state in the user detail dialog of the admin control panel’s users section +- Fixed a race condition where the portfolio snapshot computation was completed before its result had been cached, causing a redundant recomputation +- Fixed an endless loop in the portfolio snapshot computation if the computed result could not be read from the cache ## 3.40.0 - 2026-08-02 diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index bd76ef49b..ddda044a7 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,4 +1,5 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; +import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; @@ -24,6 +25,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; @@ -184,7 +186,13 @@ import { UserModule } from './user/user.module'; UserModule, WatchlistModule ], - providers: [I18nService] + providers: [ + I18nService, + { + provide: APP_FILTER, + useClass: PortfolioSnapshotComputationExceptionFilter + } + ] }) export class AppModule implements NestModule { public configure(consumer: MiddlewareConsumer) { diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts index bf45c7cda..1ab6fe3e5 100644 --- a/apps/api/src/app/import/import-data.dto.ts +++ b/apps/api/src/app/import/import-data.dto.ts @@ -2,6 +2,7 @@ import { CreateAccountWithBalancesDto, CreateAssetProfileWithMarketDataDto, CreateOrderDto, + CreatePlatformDto, CreateTagDto } from '@ghostfolio/common/dtos'; @@ -26,6 +27,12 @@ export class ImportDataDto { @ValidateNested({ each: true }) assetProfiles?: CreateAssetProfileWithMarketDataDto[]; + @IsArray() + @IsOptional() + @Type(() => CreatePlatformDto) + @ValidateNested({ each: true }) + platforms?: CreatePlatformDto[]; + @IsArray() @IsOptional() @Type(() => CreateTagDto) diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index c2d53e3cb..cd378d07d 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -77,6 +77,7 @@ export class ImportController { accountsWithBalancesDto: importData.accounts ?? [], activitiesDto: importData.activities, assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], + platformsDto: importData.platforms ?? [], tagsDto: importData.tags ?? [], user: this.request.user }); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index b8579b795..52b0662d6 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -177,6 +177,7 @@ export class ImportService { assetProfilesWithMarketDataDto, isDryRun = false, maxActivitiesToImport, + platformsDto, tagsDto, user }: { @@ -185,14 +186,54 @@ export class ImportService { assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; isDryRun?: boolean; maxActivitiesToImport: number; + platformsDto: ImportDataDto['platforms']; tagsDto: ImportDataDto['tags']; user: UserWithSettings; }): Promise { const accountIdMapping: { [oldAccountId: string]: string } = {}; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; + const platformIdMapping: { [oldPlatformId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; + if (platformsDto?.length) { + const canCreatePlatform = hasPermission( + user.permissions, + permissions.createPlatform + ); + + const existingPlatforms = await this.platformService.getPlatforms(); + + for (const platform of platformsDto) { + // Check if there is any existing platform with the same ID, otherwise + // fall back to a platform with the same URL + const existingPlatform = + existingPlatforms.find(({ id }) => { + return id === platform.id; + }) ?? + existingPlatforms.find(({ url }) => { + return url === platform.url; + }); + + if (existingPlatform) { + // Store the new to old platform ID mappings for creating accounts + if (platform.id && existingPlatform.id !== platform.id) { + platformIdMapping[platform.id] = existingPlatform.id; + } + } else { + if (!canCreatePlatform) { + throw new Error( + `Insufficient permissions to create platform ("${platform.name}")` + ); + } + + if (!isDryRun) { + await this.platformService.createPlatform(platform); + } + } + } + } + const existingTagsOfUser = tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length) ? await this.tagService.getTagsForUser(user.id) @@ -282,7 +323,8 @@ export class ImportService { ]); let oldAccountId: string; - const platformId = account.platformId; + const platformId = + platformIdMapping[account.platformId] ?? account.platformId; delete account.platformId; diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 38f942156..cdab3fdf0 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; @@ -65,6 +66,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash'; export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; + private static readonly MAX_INITIALIZATION_ATTEMPTS = 3; + protected readonly logger = new Logger(PortfolioCalculator.name); protected accountBalanceItems: HistoricalDataItem[]; @@ -174,6 +177,11 @@ export abstract class PortfolioCalculator { this.computeTransactionPoints(); this.snapshotPromise = this.initialize(); + + // Mark the rejection as handled to prevent an unhandled promise rejection + // in case the snapshot promise is never awaited. Consumers awaiting it + // still receive the error. + this.snapshotPromise.catch(() => undefined); } protected abstract calculateOverallPerformance( @@ -1124,7 +1132,7 @@ export abstract class PortfolioCalculator { } @LogPerformance - private async initialize() { + private async initialize(attempt = 1) { const startTimeTotal = performance.now(); let cachedPortfolioSnapshot: PortfolioSnapshot; @@ -1183,6 +1191,12 @@ export abstract class PortfolioCalculator { }); } } else { + if (attempt > PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS) { + throw new PortfolioSnapshotComputationError( + `Portfolio snapshot of user '${this.userId}' could not be computed after ${PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS} attempts` + ); + } + // Wait for computation await this.portfolioSnapshotService.addJobToQueue({ data: { @@ -1205,7 +1219,7 @@ export abstract class PortfolioCalculator { await job.finished(); } - await this.initialize(); + await this.initialize(attempt + 1); } } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts index fe23af04b..a6bedc55d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index 061aaf817..dc22cdbab 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index c8e0af46d..9d55a79dd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index 4922382a9..a2d576361 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts index 21a8d2056..1143e3bd2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts @@ -76,6 +76,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index 4f5da58b8..e5b0d69d6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index f20506b06..ea1df4203 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts index 79c6979ef..93d91c500 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index eb5571feb..1fa2d1264 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index aaa2f63cf..3b09bfd26 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -77,6 +77,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); exchangeRateDataService = new ExchangeRateDataService( diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts index 4e413c0c5..000cc5935 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts index 984dc1154..451973a9f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts index 962cfe2d8..2cc87934a 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts index 94654af61..68572c63e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts index b1f030aae..53236f007 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts @@ -52,6 +52,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 91c095623..f6598f22b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts index ff5dc93f9..fb7a43477 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts @@ -49,6 +49,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 8ce62db59..8c3858dcd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index 10cc2da01..364d173e1 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts index 226eaa3d8..ce5f90f5c 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts b/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts new file mode 100644 index 000000000..074ac0bae --- /dev/null +++ b/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts @@ -0,0 +1,7 @@ +export class PortfolioSnapshotComputationError extends Error { + public constructor(message: string) { + super(message); + + this.name = 'PortfolioSnapshotComputationError'; + } +} diff --git a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts index feb669ab0..2a3c1cc7a 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -18,6 +18,9 @@ export const RedisCacheServiceMock = { return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; }, + reset: () => { + RedisCacheServiceMock.cache.clear(); + }, set: (key: string, value: string): Promise => { RedisCacheServiceMock.cache.set(key, value); diff --git a/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts b/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts new file mode 100644 index 000000000..05471c3f6 --- /dev/null +++ b/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts @@ -0,0 +1,26 @@ +import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; + +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; +import { Response } from 'express'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +@Catch(PortfolioSnapshotComputationError) +export class PortfolioSnapshotComputationExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger( + PortfolioSnapshotComputationExceptionFilter.name + ); + + public catch( + exception: PortfolioSnapshotComputationError, + host: ArgumentsHost + ) { + this.logger.error(exception.message); + + const response = host.switchToHttp().getResponse(); + + response.status(StatusCodes.SERVICE_UNAVAILABLE).json({ + message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + statusCode: StatusCodes.SERVICE_UNAVAILABLE + }); + } +} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index cf94a9d2b..2ade39a8a 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -87,7 +87,7 @@ export class PortfolioSnapshotProcessor { : 0 ); - this.redisCacheService.set( + await this.redisCacheService.set( this.redisCacheService.getPortfolioSnapshotKey({ filters: job.data.filters, userId: job.data.userId diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts index 7eb09d966..fddbd01ab 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts @@ -1,32 +1,47 @@ +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; + import type { Job, JobId, JobOptions } from 'bull'; +import ms from 'ms'; import { setTimeout } from 'timers/promises'; import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; export const PortfolioSnapshotServiceMock = { - addJobToQueue({ + addJobToQueue: ({ opts }: { data: PortfolioSnapshotQueueJob; name: string; opts?: JobOptions; - }): Promise { + }): Promise => { const mockJob: Partial = { finished: async () => { await setTimeout(100); - return Promise.resolve(); + // Mimic the processor which caches the computed portfolio snapshot + // under the job id + await RedisCacheServiceMock.set( + opts?.jobId as string, + JSON.stringify({ + expiration: Date.now() + ms('1 minute'), + portfolioSnapshot: {} + } as unknown as PortfolioSnapshotValue) + ); } }; - this.jobsStore.set(opts?.jobId, mockJob); + PortfolioSnapshotServiceMock.jobsStore.set(opts?.jobId, mockJob); return Promise.resolve(mockJob as Job); }, - getJob(jobId: JobId): Promise { - const job = this.jobsStore.get(jobId); + getJob: (jobId: JobId): Promise => { + const job = PortfolioSnapshotServiceMock.jobsStore.get(jobId); return Promise.resolve(job as Job); }, - jobsStore: new Map>() + jobsStore: new Map>(), + reset: () => { + PortfolioSnapshotServiceMock.jobsStore.clear(); + } }; 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 b29ccc7d7..8592070ee 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 @@ -43,6 +43,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSort, MatSortModule, @@ -64,6 +65,7 @@ import { ellipsisVertical, trashOutline } from 'ionicons/icons'; +import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject } from 'rxjs'; @@ -88,6 +90,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in MatCheckboxModule, MatMenuModule, MatPaginatorModule, + MatSnackBarModule, MatSortModule, MatTableModule, NgxSkeletonLoaderModule, @@ -177,6 +180,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { private readonly dialog = inject(MatDialog); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); private readonly userService = inject(UserService); public constructor() { @@ -239,7 +243,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .subscribe((filters) => { this.activeFilters = filters; - this.loadData(); + this.reloadData({ pageIndex: 0 }); }); addIcons({ @@ -285,15 +289,25 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { dataSource, symbol }: AssetProfileIdentifier) { - this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); + this.adminMarketDataService + .deleteAssetProfile({ dataSource, symbol }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.reloadData(); + }); } protected onDeleteAssetProfiles() { - this.adminMarketDataService.deleteAssetProfiles( - this.selection.selected.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; - }) - ); + this.adminMarketDataService + .deleteAssetProfiles( + this.selection.selected.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.reloadData(); + }); } protected onGatherMax() { @@ -301,9 +315,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .gatherMax() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.notifyDataGatheringHasBeenStarted(); }); } @@ -311,7 +323,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { this.adminService .gatherProfileData() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); + .subscribe(() => { + this.notifyDataGatheringHasBeenStarted(); + }); } protected onGatherRecentMarketData() { @@ -319,9 +333,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { .gatherRecentMarketData() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.notifyDataGatheringHasBeenStarted(); }); } @@ -396,6 +408,16 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { }); } + private notifyDataGatheringHasBeenStarted() { + this.snackBar.open( + '✅ ' + $localize`Data gathering has been started.`, + undefined, + { + duration: ms('3 seconds') + } + ); + } + private openAssetProfileDialog({ dataSource, symbol @@ -431,6 +453,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { if (newAssetProfileIdentifier) { this.onOpenAssetProfileDialog(newAssetProfileIdentifier); } else { + this.reloadData(); + this.router.navigate(['.'], { relativeTo: this.route }); } }); @@ -483,4 +507,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { }); }); } + + private reloadData({ + pageIndex = this.paginator().pageIndex + }: { pageIndex?: number } = {}) { + this.loadData({ + pageIndex, + sortColumn: this.sort().active, + sortDirection: this.sort().direction + }); + } } diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts index 28c8c2d9f..c4d45b7f0 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.service.ts @@ -4,7 +4,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications'; import { AdminService } from '@ghostfolio/ui/services'; import { Injectable } from '@angular/core'; -import { EMPTY, catchError, finalize, forkJoin } from 'rxjs'; +import { EMPTY, Subject, catchError, finalize, forkJoin } from 'rxjs'; @Injectable() export class AdminMarketDataService { @@ -14,25 +14,29 @@ export class AdminMarketDataService { ) {} public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + const assetProfileDeleted = new Subject(); + this.notificationService.confirm({ confirmFn: () => { this.adminService .deleteProfileData({ dataSource, symbol }) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + assetProfileDeleted.next(); + assetProfileDeleted.complete(); }); }, confirmType: ConfirmationDialogType.Warn, title: $localize`Do you really want to delete this asset profile?` }); + + return assetProfileDeleted.asObservable(); } public deleteAssetProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ) { const assetProfileCount = aAssetProfileIdentifiers.length; + const assetProfilesDeleted = new Subject(); this.notificationService.confirm({ confirmFn: () => { @@ -55,7 +59,8 @@ export class AdminMarketDataService { return EMPTY; }), finalize(() => { - window.location.reload(); + assetProfilesDeleted.next(); + assetProfilesDeleted.complete(); }) ) .subscribe(); @@ -66,5 +71,7 @@ export class AdminMarketDataService { ? $localize`Do you really want to delete this asset profile?` : $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?` }); + + return assetProfilesDeleted.asObservable(); } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index f0e8eaf17..aa61845f4 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -531,9 +531,12 @@ export class GfAssetProfileDialogComponent implements OnInit { dataSource, symbol }: AssetProfileIdentifier) { - this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); - - this.dialogRef.close(); + this.adminMarketDataService + .deleteAssetProfile({ dataSource, symbol }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.dialogRef.close(); + }); } protected onGatherProfileDataBySymbol({ 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 0bed8111f..733200c91 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 @@ -64,6 +64,7 @@ import { } from 'ionicons/icons'; import ms, { StringValue } from 'ms'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { catchError, of, switchMap } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -105,6 +106,8 @@ export class GfAdminOverviewComponent implements OnInit { protected readonly info: InfoItem; protected isDataGatheringEnabled: boolean; protected isLoading = false; + protected isReadOnlyMode: boolean; + protected isUserSignupEnabled: boolean; protected readonly permissions = permissions; protected systemMessage: SystemMessage; protected userCount: number; @@ -179,6 +182,8 @@ export class GfAdminOverviewComponent implements OnInit { } public ngOnInit() { + this.isLoading = true; + this.fetchAdminData(); } @@ -270,9 +275,15 @@ export class GfAdminOverviewComponent implements OnInit { .flush() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.dataService.updateInfo(); + + this.snackBar.open( + '✅ ' + $localize`Cache has been flushed.`, + undefined, + { + duration: ms('3 seconds') + } + ); }); }, confirmType: ConfirmationDialogType.Warn, @@ -330,8 +341,6 @@ export class GfAdminOverviewComponent implements OnInit { } private fetchAdminData() { - this.isLoading = true; - this.adminService .fetchAdminData() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -344,6 +353,11 @@ export class GfAdminOverviewComponent implements OnInit { this.isDataGatheringEnabled = settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true; + this.isReadOnlyMode = settings[PROPERTY_IS_READ_ONLY_MODE] === true; + + this.isUserSignupEnabled = + settings[PROPERTY_IS_USER_SIGNUP_ENABLED] === false ? false : true; + this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage; this.userCount = userCount; this.version = version; @@ -372,11 +386,20 @@ export class GfAdminOverviewComponent implements OnInit { .putAdminSetting(key, { value: value || value === false ? JSON.stringify(value) : undefined }) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + switchMap(() => { + return this.userService.get(true); + }), + catchError(() => { + // Refresh anyway to reflect the actual state of the settings + return of(undefined); + }), + takeUntilDestroyed(this.destroyRef) + ) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.dataService.updateInfo(); + + this.fetchAdminData(); }); } 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 7e05600a6..b919f085f 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -52,9 +52,8 @@ @@ -66,7 +65,8 @@ @@ -79,6 +79,7 @@ color="primary" hideIcon="true" [checked]="isDataGatheringEnabled" + [disabled]="isLoading" (change)="onEnableDataGatheringChange($event)" /> @@ -89,7 +90,9 @@
@if (systemMessage) {
-
{{ systemMessage | json }}
+
+ {{ systemMessage | json }} +
} - @if (!info?.systemMessage) { + @if (!systemMessage) {