diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f7ea772a..da01b113a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `POST api/v1/activities` + +## 3.79.0 - 2026-10-04 + +### Added + +- Extended the _Public API_ with the liveness probe endpoint (`GET api/v1/health/liveness`) (experimental) +- Added the graceful shutdown of the server on `SIGINT` and `SIGTERM` +- Added `stop_grace_period` to the _Ghostfolio_ service in the `docker-compose` file (`docker-compose.yml`) + +### Changed + +- Localized the number formatting in the chart of the holdings tab on the home page +- Moved the dividend and the dividend yield in the holding detail dialog from experimental to general availability +- Changed the installation of the dependencies from `npm install` to `npm ci` in the `Dockerfile` - Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.3` to `14.0` +- Upgraded `nestjs` from version `11.2.3` to `11.2.6` + +### Fixed + +- Fixed an issue with the algebraic sign in the tooltip of the chart of the holdings tab on the home page +- Fixed the asset profile of the activities after a fee, an interest or a liability with the same symbol in the activities import + +### Todo + +- Add `stop_grace_period: 1m` to the _Ghostfolio_ service in your `docker-compose` file (see `docker-compose.yml`) ## 3.78.0 - 2026-10-03 diff --git a/Dockerfile b/Dockerfile index e73cd73e65..ebee4e60d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY ./package.json package.json COPY ./package-lock.json package-lock.json COPY ./prisma/schema.prisma prisma/ -RUN npm install +RUN npm ci COPY ./apps apps/ COPY ./libs libs/ @@ -39,7 +39,7 @@ WORKDIR /ghostfolio/dist/apps/api # package-lock.json needs to be used to ensure the same versions COPY ./package-lock.json /ghostfolio/dist/apps/api/ -RUN npm install +RUN npm ci COPY .config /ghostfolio/dist/apps/api/.config/ COPY prisma /ghostfolio/dist/apps/api/prisma/ diff --git a/README.md b/README.md index b9352e3bd3..59c7c5d2c6 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,8 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony ### Health Check (experimental) +The endpoint checks the connection to the database and to _Redis_. It is suitable as the readiness probe of an orchestrator (e.g. `readinessProbe` in _Kubernetes_) and as the `healthcheck` in _Docker Compose_. The check of _Redis_ can take up to 5 seconds. Thus, set the timeout of the probe to at least 5 seconds (e.g. `timeoutSeconds: 5` in _Kubernetes_). + #### Request `GET http://localhost:3333/api/v1/health` @@ -211,6 +213,28 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony } ``` +### Liveness Probe (experimental) + +The endpoint answers without a check of the database or _Redis_. It is suitable as the liveness probe of an orchestrator (e.g. `livenessProbe` in _Kubernetes_), so that an outage of a dependency after the start does not restart the container. The database migrations run before the server starts. Thus, also use the endpoint as the startup probe (e.g. `startupProbe` in _Kubernetes_), so that a long migration does not restart the container. + +#### Request + +`GET http://localhost:3333/api/v1/health/liveness` + +**Info:** No Bearer Token is required for liveness probe + +#### Response + +##### Success + +`200 OK` + +``` +{ + "status": "OK" +} +``` + ### Import Portfolio Data #### Prerequisites diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index 72d1950294..d1eb1b4873 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -4,6 +4,9 @@ import { assetProfileDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES @@ -41,6 +44,85 @@ describe('ActivitiesService', () => { ); }); + describe('getActivities', () => { + it('returns the activities with the asset profile but without the relation to the symbol profile', async () => { + const assetProfile = { + ...assetProfileDummyData, + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + }; + + const findMany = jest.fn().mockResolvedValue([ + { + ...activityDummyData, + account: null, + currency: 'USD', + date: parseDate('2021-01-01'), + fee: 1, + id: 'activity-id', + quantity: 10, + SymbolProfile: { + currency: 'USD', + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }, + tags: [], + type: 'BUY', + unitPrice: 100 + } + ]); + + const service = new ActivitiesService( + null, + null, + null, + null, + null, + null, + null, + { + toCurrencyAtDate: jest.fn().mockResolvedValue(0) + } as unknown as ExchangeRateDataService, + null, + { + order: { + findMany, + count: jest.fn().mockResolvedValue(1) + } + } as unknown as PrismaService, + { + getSymbolProfiles: jest + .fn() + .mockResolvedValue([ + { ...assetProfile, dataSource: DataSource.MANUAL }, + assetProfile + ]) + } as unknown as SymbolProfileService, + null + ); + + const { activities } = await service.getActivities({ + userCurrency: 'USD', + userId: 'user-id' + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ + SymbolProfile: { + select: { currency: true, dataSource: true, symbol: true } + } + }) + }) + ); + expect(activities).toHaveLength(1); + expect(activities[0]).not.toHaveProperty('SymbolProfile'); + expect(activities[0].assetProfile).toBe(assetProfile); + }); + }); + describe('getActivitiesForPortfolioCalculator', () => { it('leaves an activity unchanged when no splits exist', async () => { const activity = createActivity({ symbol: 'AAPL' }); diff --git a/apps/api/src/app/app.module.spec.ts b/apps/api/src/app/app.module.spec.ts new file mode 100644 index 0000000000..976e08393b --- /dev/null +++ b/apps/api/src/app/app.module.spec.ts @@ -0,0 +1,172 @@ +import { + DynamicModule, + ForwardReference, + RequestMethod, + Type +} from '@nestjs/common'; +import { + GUARDS_METADATA, + METHOD_METADATA, + MODULE_METADATA, + PATH_METADATA +} from '@nestjs/common/constants'; +import { MetadataScanner } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; + +import { AppModule } from './app.module'; + +// The packages are ECMAScript modules, which Jest cannot load +jest.mock('@openrouter/ai-sdk-provider', () => { + return {}; +}); +jest.mock('ai', () => { + return {}; +}); + +type ModuleDefinition = + DynamicModule | ForwardReference<() => Type> | Promise | Type; + +/** + * The routes of the controllers which answer a request without the + * authentication of a user or of an API key, sorted by the request method and + * the path. A new route has to apply AuthGuard('api-key') or AuthGuard('jwt') + * (e.g. via the decorator RequiresScope) or has to be added here. + * + * The MCP transport, Bull Board and the static files are not covered, as they + * are not served by a controller + */ +const PUBLIC_ROUTES = [ + 'GET /asset/:dataSource/:symbol', + 'GET /assets/:languageCode/site.webmanifest', + 'GET /auth/google', + 'GET /auth/google/callback', + 'GET /auth/oidc', + 'GET /auth/oidc/callback', + 'GET /benchmarks', + 'GET /health', + 'GET /health/ai', + 'GET /health/data-enhancer/:name', + 'GET /health/data-provider/:dataSource', + 'GET /health/liveness', + 'GET /info', + 'GET /logo', + 'GET /logo/:dataSource/:symbol', + 'GET /public/:accessId/portfolio', + 'GET /sitemap.xml', + 'GET /subscription/stripe/callback', + 'POST /auth/anonymous', + 'POST /auth/webauthn/generate-authentication-options', + 'POST /auth/webauthn/verify-authentication', + 'POST /user' +]; + +/** + * Gives the controllers of the module and of each module which it imports + */ +async function getControllers( + moduleDefinition: ModuleDefinition, + visitedModules = new Set() +): Promise { + const resolvedModule = await ('forwardRef' in moduleDefinition + ? moduleDefinition.forwardRef() + : moduleDefinition); + + if (visitedModules.has(resolvedModule)) { + return []; + } + + visitedModules.add(resolvedModule); + + const isDynamicModule = 'module' in resolvedModule; + + const controllers = [ + ...(isDynamicModule + ? (resolvedModule.controllers ?? []) + : ((Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, resolvedModule) ?? + []) as Type[])) + ]; + + const importedModules = isDynamicModule + ? [resolvedModule.module, ...(resolvedModule.imports ?? [])] + : ((Reflect.getMetadata(MODULE_METADATA.IMPORTS, resolvedModule) ?? + []) as ModuleDefinition[]); + + for (const importedModule of importedModules) { + controllers.push(...(await getControllers(importedModule, visitedModules))); + } + + return [...new Set(controllers)]; +} + +/** + * Gives the paths which the decorator of a controller or of a route sets + */ +function getPaths(target: object) { + return [ + (Reflect.getMetadata(PATH_METADATA, target) ?? '') as string | string[] + ].flat(); +} + +/** + * Gives the routes of the controller which apply neither AuthGuard('api-key') + * nor AuthGuard('jwt'), each as the request method and the path + */ +function getRoutesWithoutAuthentication(controller: Type) { + const authenticationGuards: unknown[] = [ + AuthGuard('api-key'), + AuthGuard('jwt') + ]; + const routeHandlersByName = controller.prototype as Record; + + return new MetadataScanner() + .getAllMethodNames(routeHandlersByName) + .flatMap((methodName) => { + const routeHandler = routeHandlersByName[methodName]; + const requestMethod = Reflect.getMetadata( + METHOD_METADATA, + routeHandler + ) as RequestMethod | undefined; + + if (requestMethod === undefined) { + return []; + } + + const guards = [controller, routeHandler].flatMap((target) => { + return (Reflect.getMetadata(GUARDS_METADATA, target) ?? + []) as unknown[]; + }); + + const hasAuthentication = guards.some((guard) => { + return authenticationGuards.includes(guard); + }); + + if (hasAuthentication) { + return []; + } + + return getPaths(controller).flatMap((controllerPath) => { + return getPaths(routeHandler).map((routePath) => { + const path = `${controllerPath}/${routePath}` + .split('/') + .filter(Boolean) + .join('/'); + + return `${RequestMethod[requestMethod]} /${path}`; + }); + }); + }); +} + +describe('AppModule', () => { + it('should require the authentication for each route which is not public', async () => { + const controllers = await getControllers(AppModule); + + const routesWithoutAuthentication = controllers + .flatMap((controller) => { + return getRoutesWithoutAuthentication(controller); + }) + .sort(); + + expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES); + }); +}); diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 4f88a03f0c..32aae38e3c 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -3,7 +3,8 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce import { AiServiceHealthResponse, DataEnhancerHealthResponse, - DataProviderHealthResponse + DataProviderHealthResponse, + LivenessHealthResponse } from '@ghostfolio/common/interfaces'; import { @@ -33,9 +34,11 @@ export class HealthController { @Get() public async getHealth(@Res() response: Response) { - const databaseServiceHealthy = await this.healthService.isDatabaseHealthy(); - const redisCacheServiceHealthy = - await this.healthService.isRedisCacheHealthy(); + const [databaseServiceHealthy, redisCacheServiceHealthy] = + await Promise.all([ + this.healthService.isDatabaseHealthy(), + this.healthService.isRedisCacheHealthy() + ]); if (databaseServiceHealthy && redisCacheServiceHealthy) { return response @@ -116,4 +119,9 @@ export class HealthController { .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); } } + + @Get('liveness') + public getLiveness(): LivenessHealthResponse { + return { status: getReasonPhrase(StatusCodes.OK) }; + } } diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts new file mode 100644 index 0000000000..afabdbcb1f --- /dev/null +++ b/apps/api/src/app/import/import.service.spec.ts @@ -0,0 +1,365 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; +import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; +import { CreateOrderDto } from '@ghostfolio/common/dtos'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + Activity, + AssetProfileIdentifier +} from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { DataSource, SymbolProfile } from '@prisma/client'; +import { parseISO } from 'date-fns'; + +import { ImportService } from './import.service'; + +const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; + +describe('ImportService', () => { + let createActivity: jest.Mock; + let gatherSymbols: jest.Mock; + let importService: ImportService; + + beforeEach(() => { + const configuration = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], + ENABLE_FEATURE_SUBSCRIPTION: false, + MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER + }; + + createActivity = jest.fn(); + gatherSymbols = jest.fn(); + + const validateActivities = jest.fn( + ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { + const assetProfiles: { + [assetProfileIdentifier: string]: Partial; + } = {}; + + for (const { currency, dataSource, symbol } of activitiesDto) { + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= { + currency, + dataSource, + symbol, + name: symbol + }; + } + + return assetProfiles; + } + ); + + importService = new ImportService( + { + getAccounts: jest.fn().mockResolvedValue([]) + } as unknown as AccountService, + { + createActivity, + getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 }) + } as unknown as ActivitiesService, + null, + { + get: (key: keyof typeof configuration) => { + return configuration[key]; + } + } as unknown as ConfigurationService, + { gatherSymbols } as unknown as DataGatheringService, + { + validateActivities, + getDataSourceForImport: jest.fn().mockReturnValue(DataSource.MANUAL) + } as unknown as DataProviderService, + { + toCurrencyAtDate: jest.fn().mockResolvedValue(0) + } as unknown as ExchangeRateDataService, + null, + null, + null, + null, + { + getTagsForUser: jest.fn().mockResolvedValue([]) + } as unknown as TagService + ); + }); + + describe('import', () => { + it('keeps the asset profile of an investment activity after a non-investment activity', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + + expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ + { + dataSource: DataSource.MANUAL, + date: parseISO('2024-01-01T00:00:00.000Z'), + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }, + { + dataSource: DataSource.YAHOO, + date: parseISO('2024-01-02T00:00:00.000Z'), + symbol: 'AAPL' + } + ]); + }); + + it('shows the custom asset profile of a non-investment activity after an investment activity', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + }); + + it.each(NON_INVESTMENT_ACTIVITY_TYPES)( + 'refers the next %s activities of a data provider to the custom asset profile which createActivity() has created', + async (type) => { + mockCreatedAssetProfiles([ + { + dataSource: DataSource.MANUAL, + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + type, + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL' + }), + createActivityDto({ + type, + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ symbol: CUSTOM_ASSET_PROFILE_SYMBOL }); + } + ); + + it('refers the next activities without a data source to the custom asset profile which createActivity() has created', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + date: '2024-01-01T00:00:00.000Z', + symbol: 'Broker fee', + type: 'FEE' + }), + createActivityDto({ + date: '2024-01-02T00:00:00.000Z', + symbol: 'Broker fee', + type: 'FEE' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ + dataSource: DataSource.MANUAL, + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }); + }); + + it('refers the next investment activities without a data source to the custom asset profile which createActivity() has created', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + date: '2024-01-01T00:00:00.000Z', + symbol: 'Gold', + type: 'BUY' + }), + createActivityDto({ + date: '2024-01-02T00:00:00.000Z', + symbol: 'Gold', + type: 'BUY' + }), + createActivityDto({ + date: '2024-01-03T00:00:00.000Z', + symbol: 'Gold', + type: 'SELL' + }) + ] + }); + + expect( + createActivity.mock.calls.slice(1).map(([activity]) => { + return activity.SymbolProfile.connectOrCreate.create; + }) + ).toMatchObject([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ + { + dataSource: DataSource.MANUAL, + date: parseISO('2024-01-01T00:00:00.000Z'), + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + } + ]); + }); + + it('keeps the asset profiles of the activities in a dry run', async () => { + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }) + ], + isDryRun: true + }); + + expect(createActivity).not.toHaveBeenCalled(); + expect(gatherSymbols).not.toHaveBeenCalled(); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + }); + }); + + function importActivities({ + activitiesDto, + isDryRun + }: { + activitiesDto: CreateOrderDto[]; + isDryRun?: boolean; + }) { + return importService.import({ + activitiesDto, + isDryRun, + accountsWithBalancesDto: [], + assetProfilesWithMarketDataDto: [], + platformsDto: [], + tagsDto: [], + user: { + id: 'user-id', + permissions: [], + settings: { settings: { baseCurrency: 'USD' } } + } as unknown as UserWithSettings + }); + } + + function mockCreatedAssetProfiles(assetProfiles: AssetProfileIdentifier[]) { + for (const assetProfile of assetProfiles) { + createActivity.mockImplementationOnce( + ({ + date, + type + }: Parameters[0]) => { + return { date, type, SymbolProfile: assetProfile }; + } + ); + } + } +}); + +function createActivityDto({ + dataSource, + date, + symbol, + type +}: Pick) { + return { + dataSource, + date, + symbol, + type, + currency: 'USD', + fee: 0, + quantity: 1, + unitPrice: 100 + } as CreateOrderDto; +} + +function getAssetProfileIdentifiers(activities: Activity[]) { + return activities.map(({ assetProfile: { dataSource, symbol } }) => { + return { dataSource, symbol }; + }); +} diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 8a2c80e2c3..80637ea4d6 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -836,6 +836,10 @@ export class ImportService { const activities: Activity[] = []; + const customAssetProfileSymbols: { + [assetProfileIdentifier: string]: string; + } = {}; + for (const activity of activitiesExtendedWithErrors) { const accountId = activity.accountId; const comment = activity.comment; @@ -848,12 +852,12 @@ export class ImportService { const type = activity.type; const unitPrice = activity.unitPrice; - const assetProfile = assetProfiles[ - getAssetProfileIdentifier({ - dataSource: activity.assetProfile.dataSource, - symbol: activity.assetProfile.symbol - }) - ] ?? { + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol + }); + + let assetProfile = assetProfiles[assetProfileIdentifier] ?? { dataSource: activity.assetProfile.dataSource, symbol: activity.assetProfile.symbol }; @@ -904,7 +908,14 @@ export class ImportService { continue; } - const { dataSource, name, symbol } = assetProfile; + const customAssetProfileSymbol = + NON_INVESTMENT_ACTIVITY_TYPES.includes(type) || + assetProfile.dataSource === DataSource.MANUAL + ? customAssetProfileSymbols[assetProfileIdentifier] + : undefined; + + const { dataSource, name } = assetProfile; + const symbol = customAssetProfileSymbol ?? assetProfile.symbol; const createdActivity = await this.activitiesService.createActivity({ comment, @@ -940,9 +951,15 @@ export class ImportService { userId: user.id }); - if (createdActivity.SymbolProfile?.symbol) { - // Update symbol that may have been assigned in createOrder() - assetProfile.symbol = createdActivity.SymbolProfile.symbol; + if (createdActivity.SymbolProfile.dataSource === DataSource.MANUAL) { + customAssetProfileSymbols[assetProfileIdentifier] = + createdActivity.SymbolProfile.symbol; + + assetProfile = { + ...assetProfile, + dataSource: DataSource.MANUAL, + symbol: createdActivity.SymbolProfile.symbol + }; } order = createdActivity; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 8b17cbca50..1282304c33 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -14,6 +14,7 @@ import { import { Logger, LogLevel, + ShutdownSignal, ValidationPipe, VersioningType } from '@nestjs/common'; @@ -65,6 +66,14 @@ async function bootstrap() { }); app.enableCors(); + + // Let the open requests and the active queue jobs finish on SIGINT and + // SIGTERM before the process stops. Exit explicitly because Linux ignores + // the re-raised signal if the process runs as PID 1 + app.enableShutdownHooks([ShutdownSignal.SIGINT, ShutdownSignal.SIGTERM], { + useProcessExit: true + }); + app.enableVersioning({ defaultVersion: '1', type: VersioningType.URI diff --git a/apps/api/src/services/prisma/prisma.service.ts b/apps/api/src/services/prisma/prisma.service.ts index df55e45f7c..fcf7c6e5ea 100644 --- a/apps/api/src/services/prisma/prisma.service.ts +++ b/apps/api/src/services/prisma/prisma.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, LogLevel, - OnModuleDestroy, + OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -12,7 +12,7 @@ import { Prisma, PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient - implements OnModuleInit, OnModuleDestroy + implements OnApplicationShutdown, OnModuleInit { private readonly logger = new Logger(PrismaService.name); @@ -41,6 +41,10 @@ export class PrismaService }); } + public async onApplicationShutdown() { + await this.$disconnect(); + } + public async onModuleInit() { try { await this.$connect(); @@ -48,8 +52,4 @@ export class PrismaService this.logger.error(error); } } - - public async onModuleDestroy() { - await this.$disconnect(); - } } 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 2b378a4d60..b54cc68f7d 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 @@ -183,9 +183,7 @@ >Invested Capital - @if ( - dividendInBaseCurrency && user?.settings?.isExperimentalFeatures - ) { + @if (dividendInBaseCurrency) {
{ - benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value); + benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null); labels.push(historicalDataItem.date); marketPrices.push(historicalDataItem.value); }); @@ -164,7 +168,7 @@ export class GfLineChartComponent gradient.addColorStop(1, getBackgroundColor(this.colorScheme)); } - const data = { + const data: ChartData<'line'> = { labels, datasets: [ { @@ -205,7 +209,7 @@ export class GfLineChartComponent this.chart.update(); } else { - this.chart = new Chart(this.chartCanvas.nativeElement, { + this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, { data, options: { animations: this.isAnimated ? animations : undefined, diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 01037f77f4..11c7edbf63 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -314,23 +314,18 @@ export class GfTreemapChartComponent formatter: (context: GfTreemapScriptableContext) => { const raw = context.raw as GfTreemapDataPoint; - let netPerformancePercentWithCurrencyEffect = round( - raw._data.netPerformancePercentWithCurrencyEffect, - 4 - ); - - if (Math.abs(netPerformancePercentWithCurrencyEffect) === 0) { - netPerformancePercentWithCurrencyEffect = Math.abs( - netPerformancePercentWithCurrencyEffect - ); - } - const name = raw._data.assetProfile.name; const symbol = raw._data.assetProfile.symbol; return [ isUUID(symbol) ? (name ?? symbol) : symbol, - `${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%` + `${( + raw._data.netPerformancePercentWithCurrencyEffect * 100 + ).toLocaleString(this.locale(), { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + signDisplay: 'exceptZero' + })}%` ]; }, hoverColor: undefined, @@ -402,13 +397,21 @@ export class GfTreemapChartComponent label: (context: TooltipItem<'treemap'>) => { const raw = context.raw as GfTreemapDataPoint; - const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; + const allocationInPercentage = `${( + raw._data.allocationInPercentage * 100 + ).toLocaleString(this.locale(), { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + })}%`; const name = getHoldingName(raw._data.assetProfile); - const sign = - raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; - - const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; + const netPerformanceInPercentageWithSign = `${( + raw._data.netPerformancePercentWithCurrencyEffect * 100 + ).toLocaleString(this.locale(), { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + signDisplay: 'exceptZero' + })}%`; if (raw._data.valueInBaseCurrency !== null) { const value = raw._data.valueInBaseCurrency; @@ -421,11 +424,12 @@ export class GfTreemapChartComponent })} ${this.baseCurrency()}`, '', $localize`Change` + ' (' + $localize`Performance` + ')', - `${sign}${raw._data.netPerformanceWithCurrencyEffect.toLocaleString( + `${raw._data.netPerformanceWithCurrencyEffect.toLocaleString( this.locale(), { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + signDisplay: 'exceptZero' } )} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})` ]; diff --git a/package-lock.json b/package-lock.json index 8fe0f53a49..57eb3b25a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.78.0", + "version": "3.79.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.78.0", + "version": "3.79.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -35,14 +35,14 @@ "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.5", "@nestjs/cache-manager": "3.1.3", - "@nestjs/common": "11.2.3", + "@nestjs/common": "11.2.6", "@nestjs/config": "4.0.4", - "@nestjs/core": "11.2.3", + "@nestjs/core": "11.2.6", "@nestjs/event-emitter": "3.1.0", "@nestjs/jwt": "11.0.2", - "@nestjs/microservices": "11.2.3", + "@nestjs/microservices": "11.2.6", "@nestjs/passport": "11.0.5", - "@nestjs/platform-express": "11.2.3", + "@nestjs/platform-express": "11.2.6", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@nestjs/throttler": "6.5.0", @@ -124,7 +124,7 @@ "@angular/pwa": "22.1.4", "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", - "@nestjs/testing": "11.2.3", + "@nestjs/testing": "11.2.6", "@nx/angular": "23.1.1", "@nx/eslint-plugin": "23.1.1", "@nx/jest": "23.1.1", @@ -9542,9 +9542,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.3.tgz", - "integrity": "sha512-obdauJXHfthhepbV+LpGe88OeBlR/Kw9lwjLo0Utzc//agoLXYb9DUGhPQWtm81IpBWMv+19eiwcve9MsBZwXA==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.6.tgz", + "integrity": "sha512-uXs98qEOesTf6rGs0OcvFpRdF4M6JvzyBmoIXcTYvojdte4HiwDYv+whaALoboyfu/fuq2umF74FLQu3V7ZPHw==", "license": "MIT", "dependencies": { "file-type": "21.3.4", @@ -9627,9 +9627,9 @@ } }, "node_modules/@nestjs/core": { - "version": "11.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.3.tgz", - "integrity": "sha512-vkA9/Ja0Z3hvqXErSa+HaxrfF+cNXthNFi8VPNEKVli4rMd009yExAl0gLmko/Kf8peDXr72u1RN+j9Da2ukHg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.6.tgz", + "integrity": "sha512-KZkwy6KBePPeT3EUj+NACVq6VLT5S+Msbc761NAVVyQ0FyOj7o4I85GEg/cdEPu6KdLqhJFfuqRW2SN+J2ZubQ==", "license": "MIT", "dependencies": { "fast-safe-stringify": "2.1.1", @@ -9692,9 +9692,9 @@ } }, "node_modules/@nestjs/microservices": { - "version": "11.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.2.3.tgz", - "integrity": "sha512-HcMm6c8dwKbU0CSOFYxcHZ17YkQawnVzpfT58u24/fAzmsA1WJyfCej5+uONjuG7L/CG76OSxKZlZBNzcxkUiw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.2.6.tgz", + "integrity": "sha512-vtwCIFfVWMBwuSuTfBm/EOQyWTZaVP7GZblDrpLKPggCNkHWwDBLwAd73jyn+DPcDthZQOZLmYESI/3tlaZ8KA==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -9760,14 +9760,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.3.tgz", - "integrity": "sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.6.tgz", + "integrity": "sha512-jPypI/+aueizkYG2Bw2kg8GaNsWE7ekFBSQ/Rwet7wEdBA/+q2FA364UbDabqjlR7C3VSNHmP/JXh+8+jXyDOw==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.2.0", + "multer": "2.4.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, @@ -10043,9 +10043,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.3.tgz", - "integrity": "sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.6.tgz", + "integrity": "sha512-Oua8EUw3WsnSIwAMIpvyuJxOdUxHeX04fX5MsSIpjaft5CcNnbWhSmM/u/GDDOEcAOrxOfG/KHa7LzzWdS/bxw==", "dev": true, "license": "MIT", "dependencies": { @@ -13721,67 +13721,6 @@ } } }, - "node_modules/@rekog/mcp-nest/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@rekog/mcp-nest/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@rekog/mcp-nest/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@rekog/mcp-nest/node_modules/multer": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", - "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "type-is": "^1.6.18" - }, - "engines": { - "node": ">= 10.16.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@rekog/mcp-nest/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", @@ -19039,21 +18978,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -27946,14 +27870,13 @@ } }, "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", - "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -31128,6 +31051,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -33577,6 +33501,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -35055,12 +34980,6 @@ "dev": true, "license": "MIT" }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -35372,6 +35291,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/utila": { diff --git a/package.json b/package.json index 81668dcec2..2e7b433487 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.78.0", + "version": "3.79.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -79,14 +79,14 @@ "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.5", "@nestjs/cache-manager": "3.1.3", - "@nestjs/common": "11.2.3", + "@nestjs/common": "11.2.6", "@nestjs/config": "4.0.4", - "@nestjs/core": "11.2.3", + "@nestjs/core": "11.2.6", "@nestjs/event-emitter": "3.1.0", "@nestjs/jwt": "11.0.2", - "@nestjs/microservices": "11.2.3", + "@nestjs/microservices": "11.2.6", "@nestjs/passport": "11.0.5", - "@nestjs/platform-express": "11.2.3", + "@nestjs/platform-express": "11.2.6", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@nestjs/throttler": "6.5.0", @@ -168,7 +168,7 @@ "@angular/pwa": "22.1.4", "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", - "@nestjs/testing": "11.2.3", + "@nestjs/testing": "11.2.6", "@nx/angular": "23.1.1", "@nx/eslint-plugin": "23.1.1", "@nx/jest": "23.1.1",