Browse Source

Merge branch 'main' into task/deprecate-symbol-profile-in-create-activity-endpoint

pull/8026/head
Thomas Kaul 2 weeks ago
committed by GitHub
parent
commit
ee8c35edb9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      CHANGELOG.md
  2. 4
      Dockerfile
  3. 24
      README.md
  4. 82
      apps/api/src/app/activities/activities.service.spec.ts
  5. 172
      apps/api/src/app/app.module.spec.ts
  6. 16
      apps/api/src/app/health/health.controller.ts
  7. 365
      apps/api/src/app/import/import.service.spec.ts
  8. 33
      apps/api/src/app/import/import.service.ts
  9. 9
      apps/api/src/main.ts
  10. 12
      apps/api/src/services/prisma/prisma.service.ts
  11. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  12. 1
      docker/docker-compose.yml
  13. 2
      libs/common/src/lib/interfaces/index.ts
  14. 3
      libs/common/src/lib/interfaces/responses/liveness-health-response.interface.ts
  15. 18
      libs/ui/src/lib/line-chart/line-chart.component.ts
  16. 42
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  17. 138
      package-lock.json
  18. 12
      package.json

24
CHANGELOG.md

@ -10,7 +10,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Deprecated `SymbolProfile` in favor of `assetProfile` in the endpoint `POST api/v1/activities` - 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 `@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 ## 3.78.0 - 2026-10-03

4
Dockerfile

@ -20,7 +20,7 @@ COPY ./package.json package.json
COPY ./package-lock.json package-lock.json COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/ COPY ./prisma/schema.prisma prisma/
RUN npm install RUN npm ci
COPY ./apps apps/ COPY ./apps apps/
COPY ./libs libs/ COPY ./libs libs/
@ -39,7 +39,7 @@ WORKDIR /ghostfolio/dist/apps/api
# package-lock.json needs to be used to ensure the same versions # package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/ COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install RUN npm ci
COPY .config /ghostfolio/dist/apps/api/.config/ COPY .config /ghostfolio/dist/apps/api/.config/
COPY prisma /ghostfolio/dist/apps/api/prisma/ COPY prisma /ghostfolio/dist/apps/api/prisma/

24
README.md

@ -193,6 +193,8 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony
### Health Check (experimental) ### 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 #### Request
`GET http://localhost:3333/api/v1/health` `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 ### Import Portfolio Data
#### Prerequisites #### Prerequisites

82
apps/api/src/app/activities/activities.service.spec.ts

@ -4,6 +4,9 @@ import {
assetProfileDummyData assetProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; 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 { import {
INVESTMENT_ACTIVITY_TYPES, INVESTMENT_ACTIVITY_TYPES,
NON_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', () => { describe('getActivitiesForPortfolioCalculator', () => {
it('leaves an activity unchanged when no splits exist', async () => { it('leaves an activity unchanged when no splits exist', async () => {
const activity = createActivity({ symbol: 'AAPL' }); const activity = createActivity({ symbol: 'AAPL' });

172
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<DynamicModule> | 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<DynamicModule | Type>()
): Promise<Type[]> {
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<string, object>;
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);
});
});

16
apps/api/src/app/health/health.controller.ts

@ -3,7 +3,8 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { import {
AiServiceHealthResponse, AiServiceHealthResponse,
DataEnhancerHealthResponse, DataEnhancerHealthResponse,
DataProviderHealthResponse DataProviderHealthResponse,
LivenessHealthResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
@ -33,9 +34,11 @@ export class HealthController {
@Get() @Get()
public async getHealth(@Res() response: Response) { public async getHealth(@Res() response: Response) {
const databaseServiceHealthy = await this.healthService.isDatabaseHealthy(); const [databaseServiceHealthy, redisCacheServiceHealthy] =
const redisCacheServiceHealthy = await Promise.all([
await this.healthService.isRedisCacheHealthy(); this.healthService.isDatabaseHealthy(),
this.healthService.isRedisCacheHealthy()
]);
if (databaseServiceHealthy && redisCacheServiceHealthy) { if (databaseServiceHealthy && redisCacheServiceHealthy) {
return response return response
@ -116,4 +119,9 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
@Get('liveness')
public getLiveness(): LivenessHealthResponse {
return { status: getReasonPhrase(StatusCodes.OK) };
}
} }

365
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<SymbolProfile>;
} = {};
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<ActivitiesService['createActivity']>[0]) => {
return { date, type, SymbolProfile: assetProfile };
}
);
}
}
});
function createActivityDto({
dataSource,
date,
symbol,
type
}: Pick<CreateOrderDto, 'dataSource' | 'date' | 'symbol' | 'type'>) {
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 };
});
}

33
apps/api/src/app/import/import.service.ts

@ -836,6 +836,10 @@ export class ImportService {
const activities: Activity[] = []; const activities: Activity[] = [];
const customAssetProfileSymbols: {
[assetProfileIdentifier: string]: string;
} = {};
for (const activity of activitiesExtendedWithErrors) { for (const activity of activitiesExtendedWithErrors) {
const accountId = activity.accountId; const accountId = activity.accountId;
const comment = activity.comment; const comment = activity.comment;
@ -848,12 +852,12 @@ export class ImportService {
const type = activity.type; const type = activity.type;
const unitPrice = activity.unitPrice; const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[ const assetProfileIdentifier = getAssetProfileIdentifier({
getAssetProfileIdentifier({
dataSource: activity.assetProfile.dataSource, dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol symbol: activity.assetProfile.symbol
}) });
] ?? {
let assetProfile = assetProfiles[assetProfileIdentifier] ?? {
dataSource: activity.assetProfile.dataSource, dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol symbol: activity.assetProfile.symbol
}; };
@ -904,7 +908,14 @@ export class ImportService {
continue; 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({ const createdActivity = await this.activitiesService.createActivity({
comment, comment,
@ -940,9 +951,15 @@ export class ImportService {
userId: user.id userId: user.id
}); });
if (createdActivity.SymbolProfile?.symbol) { if (createdActivity.SymbolProfile.dataSource === DataSource.MANUAL) {
// Update symbol that may have been assigned in createOrder() customAssetProfileSymbols[assetProfileIdentifier] =
assetProfile.symbol = createdActivity.SymbolProfile.symbol; createdActivity.SymbolProfile.symbol;
assetProfile = {
...assetProfile,
dataSource: DataSource.MANUAL,
symbol: createdActivity.SymbolProfile.symbol
};
} }
order = createdActivity; order = createdActivity;

9
apps/api/src/main.ts

@ -14,6 +14,7 @@ import {
import { import {
Logger, Logger,
LogLevel, LogLevel,
ShutdownSignal,
ValidationPipe, ValidationPipe,
VersioningType VersioningType
} from '@nestjs/common'; } from '@nestjs/common';
@ -65,6 +66,14 @@ async function bootstrap() {
}); });
app.enableCors(); 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({ app.enableVersioning({
defaultVersion: '1', defaultVersion: '1',
type: VersioningType.URI type: VersioningType.URI

12
apps/api/src/services/prisma/prisma.service.ts

@ -2,7 +2,7 @@ import {
Injectable, Injectable,
Logger, Logger,
LogLevel, LogLevel,
OnModuleDestroy, OnApplicationShutdown,
OnModuleInit OnModuleInit
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@ -12,7 +12,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService export class PrismaService
extends PrismaClient extends PrismaClient
implements OnModuleInit, OnModuleDestroy implements OnApplicationShutdown, OnModuleInit
{ {
private readonly logger = new Logger(PrismaService.name); private readonly logger = new Logger(PrismaService.name);
@ -41,6 +41,10 @@ export class PrismaService
}); });
} }
public async onApplicationShutdown() {
await this.$disconnect();
}
public async onModuleInit() { public async onModuleInit() {
try { try {
await this.$connect(); await this.$connect();
@ -48,8 +52,4 @@ export class PrismaService
this.logger.error(error); this.logger.error(error);
} }
} }
public async onModuleDestroy() {
await this.$disconnect();
}
} }

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

@ -183,9 +183,7 @@
>Invested Capital</gf-value >Invested Capital</gf-value
> >
</div> </div>
@if ( @if (dividendInBaseCurrency) {
dividendInBaseCurrency && user?.settings?.isExperimentalFeatures
) {
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n

1
docker/docker-compose.yml

@ -5,6 +5,7 @@ services:
container_name: ghostfolio container_name: ghostfolio
restart: unless-stopped restart: unless-stopped
init: true init: true
stop_grace_period: 1m
cap_drop: cap_drop:
- ALL - ALL
security_opt: security_opt:

2
libs/common/src/lib/interfaces/index.ts

@ -69,6 +69,7 @@ import type { ExportResponse } from './responses/export-response.interface';
import type { HistoricalResponse } from './responses/historical-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface';
import type { ImportResponse } from './responses/import-response.interface'; import type { ImportResponse } from './responses/import-response.interface';
import type { InfoResponse } from './responses/info-response.interface'; import type { InfoResponse } from './responses/info-response.interface';
import type { LivenessHealthResponse } from './responses/liveness-health-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface'; import type { LookupResponse } from './responses/lookup-response.interface';
import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface'; import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface';
@ -158,6 +159,7 @@ export {
InfoResponse, InfoResponse,
InvestmentItem, InvestmentItem,
LineChartItem, LineChartItem,
LivenessHealthResponse,
LookupItem, LookupItem,
LookupResponse, LookupResponse,
MarketData, MarketData,

3
libs/common/src/lib/interfaces/responses/liveness-health-response.interface.ts

@ -0,0 +1,3 @@
export interface LivenessHealthResponse {
status: string;
}

18
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -6,7 +6,10 @@ import {
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper'; import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces'; import {
LineChartItem,
NullableLineChartItem
} from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { import {
@ -24,6 +27,7 @@ import {
import { import {
type AnimationsSpec, type AnimationsSpec,
Chart, Chart,
type ChartData,
Filler, Filler,
LinearScale, LinearScale,
LineController, LineController,
@ -52,10 +56,10 @@ import {
export class GfLineChartComponent export class GfLineChartComponent
implements AfterViewInit, OnChanges, OnDestroy implements AfterViewInit, OnChanges, OnDestroy
{ {
@Input() benchmarkDataItems: LineChartItem[] = []; @Input() benchmarkDataItems: NullableLineChartItem[] = [];
@Input() benchmarkLabel = ''; @Input() benchmarkLabel = '';
@Input() colorScheme?: ColorScheme; @Input() colorScheme?: ColorScheme;
@Input() currency: string; @Input() currency?: string;
@Input() historicalDataItems: LineChartItem[]; @Input() historicalDataItems: LineChartItem[];
@Input() isAnimated = false; @Input() isAnimated = false;
@Input() label: string; @Input() label: string;
@ -134,12 +138,12 @@ export class GfLineChartComponent
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const benchmarkPrices: number[] = []; const benchmarkPrices: (number | null)[] = [];
const labels: string[] = []; const labels: string[] = [];
const marketPrices: number[] = []; const marketPrices: number[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => { this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value); benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null);
labels.push(historicalDataItem.date); labels.push(historicalDataItem.date);
marketPrices.push(historicalDataItem.value); marketPrices.push(historicalDataItem.value);
}); });
@ -164,7 +168,7 @@ export class GfLineChartComponent
gradient.addColorStop(1, getBackgroundColor(this.colorScheme)); gradient.addColorStop(1, getBackgroundColor(this.colorScheme));
} }
const data = { const data: ChartData<'line'> = {
labels, labels,
datasets: [ datasets: [
{ {
@ -205,7 +209,7 @@ export class GfLineChartComponent
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
animations: this.isAnimated ? animations : undefined, animations: this.isAnimated ? animations : undefined,

42
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -314,23 +314,18 @@ export class GfTreemapChartComponent
formatter: (context: GfTreemapScriptableContext) => { formatter: (context: GfTreemapScriptableContext) => {
const raw = context.raw as GfTreemapDataPoint; 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 name = raw._data.assetProfile.name;
const symbol = raw._data.assetProfile.symbol; const symbol = raw._data.assetProfile.symbol;
return [ return [
isUUID(symbol) ? (name ?? symbol) : symbol, 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, hoverColor: undefined,
@ -402,13 +397,21 @@ export class GfTreemapChartComponent
label: (context: TooltipItem<'treemap'>) => { label: (context: TooltipItem<'treemap'>) => {
const raw = context.raw as GfTreemapDataPoint; 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 name = getHoldingName(raw._data.assetProfile);
const sign = const netPerformanceInPercentageWithSign = `${(
raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; raw._data.netPerformancePercentWithCurrencyEffect * 100
).toLocaleString(this.locale(), {
const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; maximumFractionDigits: 2,
minimumFractionDigits: 2,
signDisplay: 'exceptZero'
})}%`;
if (raw._data.valueInBaseCurrency !== null) { if (raw._data.valueInBaseCurrency !== null) {
const value = raw._data.valueInBaseCurrency; const value = raw._data.valueInBaseCurrency;
@ -421,11 +424,12 @@ export class GfTreemapChartComponent
})} ${this.baseCurrency()}`, })} ${this.baseCurrency()}`,
'', '',
$localize`Change` + ' (' + $localize`Performance` + ')', $localize`Change` + ' (' + $localize`Performance` + ')',
`${sign}${raw._data.netPerformanceWithCurrencyEffect.toLocaleString( `${raw._data.netPerformanceWithCurrencyEffect.toLocaleString(
this.locale(), this.locale(),
{ {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
signDisplay: 'exceptZero'
} }
)} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})` )} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})`
]; ];

138
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.78.0", "version": "3.79.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.78.0", "version": "3.79.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -35,14 +35,14 @@
"@nest-lab/throttler-storage-redis": "1.2.0", "@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bull": "11.0.5", "@nestjs/bull": "11.0.5",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.2.3", "@nestjs/common": "11.2.6",
"@nestjs/config": "4.0.4", "@nestjs/config": "4.0.4",
"@nestjs/core": "11.2.3", "@nestjs/core": "11.2.6",
"@nestjs/event-emitter": "3.1.0", "@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.2",
"@nestjs/microservices": "11.2.3", "@nestjs/microservices": "11.2.6",
"@nestjs/passport": "11.0.5", "@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.2.3", "@nestjs/platform-express": "11.2.6",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
@ -124,7 +124,7 @@
"@angular/pwa": "22.1.4", "@angular/pwa": "22.1.4",
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0", "@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.2.3", "@nestjs/testing": "11.2.6",
"@nx/angular": "23.1.1", "@nx/angular": "23.1.1",
"@nx/eslint-plugin": "23.1.1", "@nx/eslint-plugin": "23.1.1",
"@nx/jest": "23.1.1", "@nx/jest": "23.1.1",
@ -9542,9 +9542,9 @@
} }
}, },
"node_modules/@nestjs/common": { "node_modules/@nestjs/common": {
"version": "11.2.3", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.6.tgz",
"integrity": "sha512-obdauJXHfthhepbV+LpGe88OeBlR/Kw9lwjLo0Utzc//agoLXYb9DUGhPQWtm81IpBWMv+19eiwcve9MsBZwXA==", "integrity": "sha512-uXs98qEOesTf6rGs0OcvFpRdF4M6JvzyBmoIXcTYvojdte4HiwDYv+whaALoboyfu/fuq2umF74FLQu3V7ZPHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"file-type": "21.3.4", "file-type": "21.3.4",
@ -9627,9 +9627,9 @@
} }
}, },
"node_modules/@nestjs/core": { "node_modules/@nestjs/core": {
"version": "11.2.3", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.6.tgz",
"integrity": "sha512-vkA9/Ja0Z3hvqXErSa+HaxrfF+cNXthNFi8VPNEKVli4rMd009yExAl0gLmko/Kf8peDXr72u1RN+j9Da2ukHg==", "integrity": "sha512-KZkwy6KBePPeT3EUj+NACVq6VLT5S+Msbc761NAVVyQ0FyOj7o4I85GEg/cdEPu6KdLqhJFfuqRW2SN+J2ZubQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-safe-stringify": "2.1.1", "fast-safe-stringify": "2.1.1",
@ -9692,9 +9692,9 @@
} }
}, },
"node_modules/@nestjs/microservices": { "node_modules/@nestjs/microservices": {
"version": "11.2.3", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.2.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.2.6.tgz",
"integrity": "sha512-HcMm6c8dwKbU0CSOFYxcHZ17YkQawnVzpfT58u24/fAzmsA1WJyfCej5+uONjuG7L/CG76OSxKZlZBNzcxkUiw==", "integrity": "sha512-vtwCIFfVWMBwuSuTfBm/EOQyWTZaVP7GZblDrpLKPggCNkHWwDBLwAd73jyn+DPcDthZQOZLmYESI/3tlaZ8KA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"iterare": "1.2.1", "iterare": "1.2.1",
@ -9760,14 +9760,14 @@
} }
}, },
"node_modules/@nestjs/platform-express": { "node_modules/@nestjs/platform-express": {
"version": "11.2.3", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.6.tgz",
"integrity": "sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==", "integrity": "sha512-jPypI/+aueizkYG2Bw2kg8GaNsWE7ekFBSQ/Rwet7wEdBA/+q2FA364UbDabqjlR7C3VSNHmP/JXh+8+jXyDOw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cors": "2.8.6", "cors": "2.8.6",
"express": "5.2.1", "express": "5.2.1",
"multer": "2.2.0", "multer": "2.4.0",
"path-to-regexp": "8.4.2", "path-to-regexp": "8.4.2",
"tslib": "2.8.1" "tslib": "2.8.1"
}, },
@ -10043,9 +10043,9 @@
} }
}, },
"node_modules/@nestjs/testing": { "node_modules/@nestjs/testing": {
"version": "11.2.3", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.6.tgz",
"integrity": "sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==", "integrity": "sha512-Oua8EUw3WsnSIwAMIpvyuJxOdUxHeX04fX5MsSIpjaft5CcNnbWhSmM/u/GDDOEcAOrxOfG/KHa7LzzWdS/bxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz",
@ -19039,21 +18978,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/confusing-browser-globals": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
@ -27946,14 +27870,13 @@
} }
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.2.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.6.0", "busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18" "type-is": "^1.6.18"
}, },
"engines": { "engines": {
@ -31128,6 +31051,7 @@
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
@ -33577,6 +33501,7 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
@ -35055,12 +34980,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@ -35372,6 +35291,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/utila": { "node_modules/utila": {

12
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.78.0", "version": "3.79.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -79,14 +79,14 @@
"@nest-lab/throttler-storage-redis": "1.2.0", "@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bull": "11.0.5", "@nestjs/bull": "11.0.5",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.2.3", "@nestjs/common": "11.2.6",
"@nestjs/config": "4.0.4", "@nestjs/config": "4.0.4",
"@nestjs/core": "11.2.3", "@nestjs/core": "11.2.6",
"@nestjs/event-emitter": "3.1.0", "@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.2",
"@nestjs/microservices": "11.2.3", "@nestjs/microservices": "11.2.6",
"@nestjs/passport": "11.0.5", "@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.2.3", "@nestjs/platform-express": "11.2.6",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
@ -168,7 +168,7 @@
"@angular/pwa": "22.1.4", "@angular/pwa": "22.1.4",
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0", "@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.2.3", "@nestjs/testing": "11.2.6",
"@nx/angular": "23.1.1", "@nx/angular": "23.1.1",
"@nx/eslint-plugin": "23.1.1", "@nx/eslint-plugin": "23.1.1",
"@nx/jest": "23.1.1", "@nx/jest": "23.1.1",

Loading…
Cancel
Save