Browse Source

Merge branch 'main' into task/upgrade-nestjs-to-version-11.2

pull/7779/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
8c784a395f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 26
      CHANGELOG.md
  2. 4
      apps/api/src/app/account/get-all-accounts.dto.ts
  3. 5
      apps/api/src/app/endpoints/ai/ai.controller.ts
  4. 48
      apps/api/src/app/endpoints/ai/ai.module.ts
  5. 164
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  6. 454
      apps/api/src/app/endpoints/ai/ai.service.ts
  7. 25
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  8. 64
      apps/api/src/app/endpoints/asset-profiles/get-asset-profiles.dto.ts
  9. 16
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-lookup.dto.ts
  10. 5
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  11. 334
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  12. 290
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  13. 12
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  14. 48
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  15. 115
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  16. 279
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  17. 176
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  18. 18
      apps/api/src/app/endpoints/mcp/mcp.test-utils.ts
  19. 7
      apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts
  20. 4
      apps/api/src/app/import/errors/import-validation.error.ts
  21. 4
      apps/api/src/app/portfolio/get-holdings.dto.ts
  22. 16
      apps/api/src/app/symbol/get-lookup.dto.ts
  23. 6
      apps/api/src/app/symbol/symbol.controller.ts
  24. 4
      apps/api/src/app/user/user.service.ts
  25. 39
      apps/api/src/dtos/filter.dto.ts
  26. 12
      apps/api/src/errors/caller-facing.error.ts
  27. 83
      apps/api/src/filters/mcp-tool-exception.filter.spec.ts
  28. 30
      apps/api/src/filters/mcp-tool-exception.filter.ts
  29. 6
      apps/api/src/helper/interfaces/table-column-definition.interface.ts
  30. 7
      apps/api/src/helper/interfaces/table-parameters.interface.ts
  31. 77
      apps/api/src/helper/markdown-table.helper.spec.ts
  32. 44
      apps/api/src/helper/markdown-table.helper.ts
  33. 18
      apps/api/src/services/api/api.service.ts
  34. 2
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  35. 6
      apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts
  36. 14
      apps/api/src/services/portfolio-table/portfolio-table.module.ts
  37. 272
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  38. 399
      apps/api/src/services/portfolio-table/portfolio-table.service.ts
  39. 9
      apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts
  40. 8
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  41. 38
      apps/client/src/app/components/access-table/access-table.component.html
  42. 26
      apps/client/src/app/components/access-table/access-table.component.ts
  43. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  44. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  45. 37
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  46. 12
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  47. 84
      apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
  48. 3
      apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts
  49. 86
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  50. 1
      apps/client/src/app/pages/accounts/accounts-page.html
  51. 9
      apps/client/src/app/pages/accounts/accounts-page.routes.ts
  52. 7
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  53. 1
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  54. 4
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  55. 7
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  56. 1
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  57. 468
      apps/client/src/locales/messages.ca.xlf
  58. 468
      apps/client/src/locales/messages.de.xlf
  59. 468
      apps/client/src/locales/messages.es.xlf
  60. 468
      apps/client/src/locales/messages.fr.xlf
  61. 518
      apps/client/src/locales/messages.it.xlf
  62. 468
      apps/client/src/locales/messages.ja.xlf
  63. 468
      apps/client/src/locales/messages.ko.xlf
  64. 468
      apps/client/src/locales/messages.nl.xlf
  65. 468
      apps/client/src/locales/messages.pl.xlf
  66. 468
      apps/client/src/locales/messages.pt.xlf
  67. 468
      apps/client/src/locales/messages.tr.xlf
  68. 468
      apps/client/src/locales/messages.uk.xlf
  69. 466
      apps/client/src/locales/messages.xlf
  70. 468
      apps/client/src/locales/messages.zh.xlf
  71. 5
      libs/common/src/lib/config.ts
  72. 3
      libs/common/src/lib/dtos/create-account.dto.ts
  73. 7
      libs/common/src/lib/dtos/create-asset-profile.dto.ts
  74. 7
      libs/common/src/lib/dtos/create-order.dto.ts
  75. 5
      libs/common/src/lib/dtos/create-watchlist-item.dto.ts
  76. 3
      libs/common/src/lib/dtos/update-account.dto.ts
  77. 7
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  78. 7
      libs/common/src/lib/dtos/update-order.dto.ts
  79. 20
      libs/common/src/lib/personal-finance-tools.ts
  80. 5
      libs/common/src/lib/routes/routes.ts
  81. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  82. 8
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  83. 2
      libs/ui/src/lib/assistant/assistant.component.ts
  84. 1
      libs/ui/src/lib/assistant/assistant.html
  85. 1
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html
  86. 2
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  87. 120
      package-lock.json
  88. 12
      package.json

26
CHANGELOG.md

@ -7,13 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Introduced a DTO for the query parameters of the asset profiles endpoint
- Introduced a DTO for the query parameters of the symbol lookup endpoints
### Changed ### Changed
- Restricted the _Restricted view and manage_ permission of the access to share the portfolio to the Model Context Protocol (MCP) (experimental) - Improved the server of the Model Context Protocol (MCP) (experimental)
- Introduced a maximum length for the comment in the API endpoints
- Introduced a maximum length for the search query and the symbol in the API endpoints
- Hardened the validation of the query parameters (`accounts`, `assetClasses`, `dataSource` and `tags`) in the API endpoints with filters
- Upgraded `nestjs` from version `11.1.28` to `11.2.1` - Upgraded `nestjs` from version `11.1.28` to `11.2.1`
### Fixed ### Fixed
- Fixed the immediate expiration of a portfolio snapshot with errors
- Fixed the missing country mapping of _Congo (Dem. Rep. of the)_ and _Congo (Rep. of)_ in the _Financial Modeling Prep_ service
## 3.66.0 - 2026-09-03
### Changed
- Moved the details of the granted access from the table to the dialog on the access page (experimental)
- Restricted the _Restricted view and manage_ permission of the access to share the portfolio to the Model Context Protocol (MCP) (experimental)
- Migrated the transfer cash balance dialog to a dedicated route
- Improved the language localization for Italian (`it`)
- Upgraded `@rekog/mcp-nest` from version `2.0.0` to `2.0.2`
- Upgraded `prisma` from version `7.9.1` to `7.10.0`
### Fixed
- Fixed the loading state of the accounts table on the accounts page - Fixed the loading state of the accounts table on the accounts page
- Fixed the loading state of the holdings table on the portfolio holdings page - Fixed the loading state of the holdings table on the portfolio holdings page

4
apps/api/src/app/account/get-all-accounts.dto.ts

@ -1,9 +1,11 @@
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString, MaxLength } from 'class-validator';
export class GetAllAccountsDto extends FilterDto { export class GetAllAccountsDto extends FilterDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string; query?: string;
} }

5
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto'; import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -12,7 +13,8 @@ import {
Inject, Inject,
Param, Param,
Query, Query,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -30,6 +32,7 @@ export class AiController {
@Get('prompt/:mode') @Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt) @HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getPrompt( public async getPrompt(
@Param('mode') mode: AiPromptMode, @Param('mode') mode: AiPromptMode,
@Query() @Query()

48
apps/api/src/app/endpoints/ai/ai.module.ts

@ -1,26 +1,8 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -31,32 +13,12 @@ import { AiService } from './ai.service';
controllers: [AiController], controllers: [AiController],
exports: [AiService], exports: [AiService],
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, PortfolioTableModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, TransformDataSourceInRequestModule
SymbolProfileModule,
TagModule,
UserModule
], ],
providers: [ providers: [AiService]
AccountBalanceService,
AccountService,
AiService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
}) })
export class AiModule {} export class AiModule {}

164
apps/api/src/app/endpoints/ai/ai.service.spec.ts

@ -1,164 +0,0 @@
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { AccountWithValue } from '@ghostfolio/common/types';
import { AiService } from './ai.service';
// The service imports two packages which ship as an ECMAScript module only,
// which Jest cannot transform. The mocks only make the imports resolvable,
// because no test calls them.
jest.mock('@openrouter/ai-sdk-provider', () => {
return { createOpenRouter: jest.fn() };
});
jest.mock('ai', () => {
return { generateText: jest.fn() };
});
/**
* The markdown table is rendered by a package which ships as an ECMAScript
* module only, hence the service loads it with a dynamic import which Jest
* cannot run. The tests replace the method by a simple renderer, so that they
* can read the columns and the rows which the service gives to it.
*/
interface AiServiceWithMarkdownTable {
getMarkdownTable(parameters: {
columnDefinitions: readonly { name: string }[];
rows: Record<string, string>[];
}): Promise<string>;
}
function createAccount({
id = 'account-a-id',
isExcluded = false,
name = 'Account A'
}: {
id?: string;
isExcluded?: boolean;
name?: string;
} = {}) {
return {
id,
name,
activitiesCount: 3,
allocationInPercentage: 0.25,
balance: 1000,
currency: 'CHF',
platform: { name: 'Platform A' },
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [],
value: 2000
} as unknown as AccountWithValue;
}
function createAiService(accounts: AccountWithValue[]) {
const portfolioService = {
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts })
} as unknown as PortfolioService;
const aiService = new AiService(null, null, null, portfolioService, null);
jest
.spyOn(
aiService as unknown as AiServiceWithMarkdownTable,
'getMarkdownTable'
)
.mockImplementation(async ({ columnDefinitions, rows }) => {
const columnNames = columnDefinitions.map(({ name }) => {
return name;
});
return [
columnNames.join(' | '),
...rows.map((row) => {
return columnNames
.map((columnName) => {
return row[columnName];
})
.join(' | ');
})
].join('\n');
});
return aiService;
}
describe('AiService', () => {
// The tools of the model context protocol are the only callers, and an
// access of that type never grants the scope to read the monetary values,
// hence no table has a column with such a value
describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(AiService.getAccountsTableColumnNames()).toEqual([
'Id',
'Name',
'Currency',
'Platform',
'Activities Count',
'Allocation in Percentage',
'Excluded from Analysis'
]);
});
});
describe('getActivitiesTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(AiService.getActivitiesTableColumnNames()).toEqual([
'Date',
'Type',
'Name',
'Symbol',
'Currency',
'Unit Price',
'Account'
]);
});
});
describe('getAccountsTable', () => {
it('gives no cash balance and no value of an account', async () => {
const aiService = createAiService([createAccount()]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).not.toContain('Cash Balance');
expect(result).not.toContain('1000');
expect(result).not.toContain('2000');
});
// The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them
it('gives the identifier of an account', async () => {
const aiService = createAiService([createAccount()]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).toContain('account-a-id');
});
it('marks an account which is excluded from the analysis', async () => {
const aiService = createAiService([
createAccount({ isExcluded: true }),
createAccount({ id: 'account-b-id', name: 'Account B' })
]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
const [rowOfAccountA, rowOfAccountB] = result
.split('\n')
.filter((line) => {
return line.startsWith('account-');
});
expect(rowOfAccountA).toContain('true');
expect(rowOfAccountB).toContain('false');
});
it('tells that no accounts are found if the result is empty', async () => {
const aiService = createAiService([]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).toContain('No accounts found.');
});
});
});

454
apps/api/src/app/endpoints/ai/ai.service.ts

@ -1,122 +1,25 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types'; import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { format } from 'date-fns';
import type { ColumnDescriptor } from 'tablemark';
@Injectable() @Injectable()
export class AiService { export class AiService {
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACTIVITIES_COUNT'
| 'ALLOCATION_PERCENTAGE'
| 'CURRENCY'
| 'EXCLUDED_FROM_ANALYSIS'
| 'ID'
| 'NAME'
| 'PLATFORM';
} & ColumnDescriptor)[] = [
{ key: 'ID', name: 'Id' },
{ key: 'NAME', name: 'Name' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'PLATFORM', name: 'Platform' },
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' },
{
align: 'right',
key: 'ALLOCATION_PERCENTAGE',
name: 'Allocation in Percentage'
},
{ key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' }
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACCOUNT'
| 'CURRENCY'
| 'DATE'
| 'NAME'
| 'SYMBOL'
| 'TYPE'
| 'UNIT_PRICE';
} & ColumnDescriptor)[] = [
{ key: 'DATE', name: 'Date' },
{ key: 'TYPE', name: 'Type' },
{ key: 'NAME', name: 'Name' },
{ key: 'SYMBOL', name: 'Symbol' },
{ key: 'CURRENCY', name: 'Currency' },
{ align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' },
{ key: 'ACCOUNT', name: 'Account' }
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({
key:
| 'ACTIVITIES_COUNT'
| 'ALLOCATION_PERCENTAGE'
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'CURRENCY'
| 'DATE_OF_FIRST_ACTIVITY'
| 'NAME'
| 'SYMBOL';
} & ColumnDescriptor)[] = [
{ key: 'NAME', name: 'Name' },
{ key: 'SYMBOL', name: 'Symbol' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'ASSET_CLASS', name: 'Asset Class' },
{ key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' },
{ key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' },
{ align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' },
{
align: 'right',
key: 'ALLOCATION_PERCENTAGE',
name: 'Allocation in Percentage'
}
];
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService, private readonly portfolioTableService: PortfolioTableService,
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public static getAccountsTableColumnNames() {
return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public static getActivitiesTableColumnNames() {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public static getHoldingsTableColumnNames() {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ name }) => {
return name;
});
}
public async generateText({ public async generateText({
prompt, prompt,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
@ -143,191 +46,6 @@ export class AiService {
}); });
} }
public async getAccountsTable({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}) {
const { accounts } =
await this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
});
const accountsTableRows = accounts.map(
({
activitiesCount,
allocationInPercentage,
currency,
id,
name: label,
platform,
tags
}) => {
return AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACTIVITIES_COUNT':
row[name] = activitiesCount.toString();
break;
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'CURRENCY':
row[name] = currency ?? '';
break;
case 'EXCLUDED_FROM_ANALYSIS':
row[name] = isAccountExcluded({ tags }).toString();
break;
case 'ID':
row[name] = id;
break;
case 'NAME':
row[name] = label ?? '';
break;
case 'PLATFORM':
row[name] = platform?.name ?? '';
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const accountsSection = ['## Accounts', ''];
if (accountsTableRows.length > 0) {
accountsSection.push(
await this.getMarkdownTable({
columnDefinitions: AiService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
rows: accountsTableRows
})
);
} else {
accountsSection.push('No accounts found.');
}
return accountsSection.join('\n');
}
public async getActivitiesTable({
endDate,
filters,
skip = 0,
startDate,
take,
types,
userCurrency,
userId
}: {
endDate?: Date;
filters?: Filter[];
skip?: number;
startDate?: Date;
take: number;
types?: ActivityType[];
userCurrency: string;
userId: string;
}) {
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
startDate,
take,
types,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'desc',
withExcludedAccountsAndActivities: true
});
const activitiesTableRows = activities.map(
({ account, assetProfile, currency, date, type, unitPrice }) => {
return AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACCOUNT':
row[name] = account?.name ?? '';
break;
case 'CURRENCY':
row[name] = currency ?? assetProfile.currency;
break;
case 'DATE':
row[name] = format(date, DATE_FORMAT);
break;
case 'NAME':
row[name] = assetProfile.name ?? '';
break;
case 'SYMBOL':
row[name] = assetProfile.symbol;
break;
case 'TYPE':
row[name] = type;
break;
case 'UNIT_PRICE':
row[name] = unitPrice.toString();
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const activitiesSection = [
'## Activities',
'',
this.getActivitiesSummary({
count,
skip,
numberOfActivities: activities.length
})
];
if (activitiesTableRows.length > 0) {
activitiesSection.push(
'',
await this.getMarkdownTable({
columnDefinitions: AiService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
rows: activitiesTableRows
})
);
}
return activitiesSection.join('\n');
}
public async getPrompt({ public async getPrompt({
filters, filters,
languageCode, languageCode,
@ -341,98 +59,12 @@ export class AiService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const holdingsSection = await this.portfolioTableService.getHoldingsTable({
filters, filters,
userId
});
const assetClassTranslations = this.getEnumTranslations({
languageCode, languageCode,
id: 'assetClass', userId
values: Object.values(AssetClass)
});
const assetSubClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetSubClass',
values: Object.values(AssetSubClass)
}); });
const holdingsTableRows = [...holdings]
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
activitiesCount,
allocationInPercentage,
assetProfile: {
assetClass,
assetSubClass,
currency,
name: label,
symbol
},
dateOfFirstActivity
}) => {
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce(
(row, { key, name }) => {
switch (key) {
case 'ACTIVITIES_COUNT':
row[name] = activitiesCount.toString();
break;
case 'ALLOCATION_PERCENTAGE':
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`;
break;
case 'ASSET_CLASS':
row[name] = assetClassTranslations[assetClass] ?? '';
break;
case 'ASSET_SUB_CLASS':
row[name] = assetSubClassTranslations[assetSubClass] ?? '';
break;
case 'CURRENCY':
row[name] = currency;
break;
case 'DATE_OF_FIRST_ACTIVITY':
row[name] = dateOfFirstActivity
? format(dateOfFirstActivity, DATE_FORMAT)
: '';
break;
case 'NAME':
row[name] = label;
break;
case 'SYMBOL':
row[name] = symbol;
break;
default:
row[name] = '';
break;
}
return row;
},
{} as Record<string, string>
);
}
);
const holdingsSection = [
'## Holdings',
'',
await this.getMarkdownTable({
columnDefinitions: AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
rows: holdingsTableRows
})
].join('\n');
if (mode === 'portfolio') { if (mode === 'portfolio') {
return holdingsSection; return holdingsSection;
} }
@ -451,82 +83,4 @@ export class AiService {
`Provide your answer in the following language: ${languageCode}.` `Provide your answer in the following language: ${languageCode}.`
].join('\n'); ].join('\n');
} }
private getActivitiesSummary({
count,
numberOfActivities,
skip
}: {
count: number;
numberOfActivities: number;
skip: number;
}) {
if (count === 0) {
return 'No activities found.';
}
if (numberOfActivities === 0) {
return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`;
}
if (numberOfActivities === count) {
return `Showing all ${count} activities, the most recent first.`;
}
const lastActivity = skip + numberOfActivities;
const summary = `Showing the activities ${
skip + 1
} to ${lastActivity} of ${count}, the most recent first.`;
if (lastActivity === count) {
return summary;
}
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`;
}
private getEnumTranslations<T extends string>({
id,
languageCode,
values
}: {
id: string;
languageCode: string;
values: T[];
}) {
return values.reduce(
(translations, value) => {
translations[value] =
this.i18nService.getTranslation({
languageCode,
id: `${id}.${value}`
}) || value;
return translations;
},
{} as Record<T, string>
);
}
private async getMarkdownTable({
columnDefinitions,
rows
}: {
columnDefinitions: readonly ColumnDescriptor[];
rows: Record<string, string>[];
}) {
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
return tablemark(rows, {
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
})
});
}
} }

25
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -17,7 +17,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission } from '@ghostfolio/common/permissions'; import { hasPermission } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -27,7 +27,6 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Patch, Patch,
Post, Post,
Query, Query,
@ -36,11 +35,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client'; import { AssetProfileSplit, DataSource } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service'; import { AssetProfilesService } from './asset-profiles.service';
import { GetAssetProfilesDto } from './get-asset-profiles.dto';
@AllowDuringImpersonation() @AllowDuringImpersonation()
@Controller('asset-profiles') @Controller('asset-profiles')
@ -56,14 +56,17 @@ export class AssetProfilesController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAssetProfiles( public async getAssetProfiles(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query()
@Query('dataSource') filterByDataSource?: string, {
@Query('presetId') presetId?: MarketDataPreset, assetSubClasses: filterByAssetSubClasses,
@Query('query') filterBySearchQuery?: string, dataSource: filterByDataSource,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number, presetId,
@Query('sortColumn') sortColumn?: string, query: filterBySearchQuery,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, skip,
@Query('take', new ParseIntPipe({ optional: true })) take?: number sortColumn,
sortDirection,
take
}: GetAssetProfilesDto
): Promise<AssetProfilesResponse> { ): Promise<AssetProfilesResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses, filterByAssetSubClasses,

64
apps/api/src/app/endpoints/asset-profiles/get-asset-profiles.dto.ts

@ -0,0 +1,64 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsOptional,
IsString,
MaxLength,
Min
} from 'class-validator';
export class GetAssetProfilesDto {
@IsOptional()
@IsString()
assetSubClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsIn([
'BENCHMARKS',
'CURRENCIES',
'ETF_WITHOUT_COUNTRIES',
'ETF_WITHOUT_SECTORS',
'NO_ACTIVITIES'
] as MarketDataPreset[])
@IsOptional()
presetId?: MarketDataPreset;
@IsOptional()
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn([
'activitiesCount',
'assetClass',
'assetSubClass',
'dataSource',
'symbol'
])
@IsOptional()
sortColumn?: string;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

16
apps/api/src/app/endpoints/data-providers/ghostfolio/get-lookup.dto.ts

@ -0,0 +1,16 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsString, MaxLength } from 'class-validator';
export class GetLookupDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
includeIndices? = false;
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query? = '';
}

5
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -32,6 +32,7 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto'; import { GetDividendsDto } from './get-dividends.dto';
import { GetHistoricalDto } from './get-historical.dto'; import { GetHistoricalDto } from './get-historical.dto';
import { GetLookupDto } from './get-lookup.dto';
import { GetQuotesDto } from './get-quotes.dto'; import { GetQuotesDto } from './get-quotes.dto';
import { GhostfolioService } from './ghostfolio.service'; import { GhostfolioService } from './ghostfolio.service';
@ -142,10 +143,8 @@ export class GhostfolioController {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2') @Version('2')
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false', @Query() { includeIndices, query }: GetLookupDto
@Query('query') query = ''
): Promise<LookupResponse> { ): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
await this.validateDailyRequestLimit(); await this.validateDailyRequestLimit();
try { try {

334
apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts

@ -1,285 +1,87 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import { AccessGuard } from '@ghostfolio/api/guards/access.guard';
import { Activity } from '@ghostfolio/common/interfaces'; import { Scope, scopes } from '@ghostfolio/common/scopes';
import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import type {
ImpersonationContext,
UserWithSettings
} from '@ghostfolio/common/types';
import { HttpException, Logger } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { DataSource, Type as ActivityType } from '@prisma/client';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { import {
GhostfolioMcpController, EXCEPTION_FILTERS_METADATA,
IMPORT_ACTIVITIES_PARAMETERS GUARDS_METADATA
} from './mcp.controller'; } from '@nestjs/common/constants';
import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest';
// The controller reads the columns of the tables from the AiService, which
// imports two packages which ship as an ECMAScript module only, which Jest import { GhostfolioMcpController } from './mcp.controller';
// cannot transform. The mocks only make the imports resolvable, because no
// test calls them. /**
jest.mock('@openrouter/ai-sdk-provider', () => { * Gives the metadata which a decorator sets on the method of a tool. The
return { createOpenRouter: jest.fn() }; * prototype is read by the name of the method, hence the type of the metadata
}); * is given by the caller.
*/
jest.mock('ai', () => { function getMetadataOfMethod<T>(metadataKey: string, methodName: string) {
return { generateText: jest.fn() }; const methodsByName = GhostfolioMcpController.prototype as unknown as Record<
}); string,
object
>;
return Reflect.getMetadata(metadataKey, methodsByName[methodName]) as T;
}
function createActivity(overrides: Record<string, unknown> = {}) { function getToolMethodNames() {
return { return Object.getOwnPropertyNames(GhostfolioMcpController.prototype).filter(
currency: 'USD', (methodName) => {
date: '2024-01-01', return Boolean(
fee: 0, getMetadataOfMethod<ToolMetadata>(MCP_TOOL_METADATA_KEY, methodName)
quantity: 1, );
symbol: 'AAPL', }
type: ActivityType.BUY, );
unitPrice: 100,
...overrides
};
} }
describe('GhostfolioMcpController', () => { describe('GhostfolioMcpController', () => {
const impersonation = { userId: 'user-id' } as ImpersonationContext; // A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope
let configuration: Record<string, unknown>; it('Requires a scope of access for each tool', () => {
let configurationService: ConfigurationService; const toolMethodNames = getToolMethodNames();
let controller: GhostfolioMcpController;
let importService: ImportService;
let userService: UserService;
function setupUser(userPermissions: string[]) { expect(toolMethodNames.length).toBeGreaterThan(0);
jest.spyOn(userService, 'user').mockResolvedValue({
permissions: userPermissions
} as UserWithSettings);
}
beforeEach(() => { const toolMethodNamesWithoutScope = toolMethodNames.filter((methodName) => {
configuration = { return !getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, methodName)
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], ?.length;
ENABLE_FEATURE_SUBSCRIPTION: false });
};
configurationService = {
get: jest.fn().mockImplementation((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
userService = { user: jest.fn() } as unknown as UserService; expect(toolMethodNamesWithoutScope).toEqual([]);
});
controller = new GhostfolioMcpController( // The decorator RequiresScope sets the same metadata as the decorator
undefined, // RequiresScopeOfAccess, but applies AuthGuard('jwt'), which a request of
undefined, // an access cannot pass, hence the guards tell the two decorators apart
configurationService, it('Applies the guard of the access to each tool', () => {
importService, const toolMethodNames = getToolMethodNames();
userService
expect(toolMethodNames.length).toBeGreaterThan(0);
const toolMethodNamesWithoutGuardOfAccess = toolMethodNames.filter(
(methodName) => {
return !getMetadataOfMethod<unknown[]>(
GUARDS_METADATA,
methodName
)?.includes(AccessGuard);
}
); );
});
afterEach(() => { expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
jest.restoreAllMocks();
}); });
describe('Import activities', () => { it('Requires the scope to create an activity for the tool to import activities', () => {
it('Requires the scope to create an activity', () => { expect(
expect( getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')
Reflect.getMetadata( ).toEqual([scopes.activityCreate]);
REQUIRES_SCOPE_KEY,
GhostfolioMcpController.prototype.importActivities
)
).toEqual([scopes.activityCreate]);
});
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(HttpException);
expect(importService.import).not.toHaveBeenCalled();
});
it('Gives the number of the imported and of the skipped activities', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockResolvedValue([{ id: 'activity-id' } as Activity]);
expect(
await controller.importActivities(impersonation, {
activities: [createActivity(), createActivity({ quantity: 2 })]
})
).toEqual({
content: [
{
text: 'Imported activities: 1\nSkipped duplicate activities: 1',
type: 'text'
}
]
});
});
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO })
]
})
);
});
it('Passes on the message of a validation only', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new ImportValidationError('activities.0.symbol ("X") is not valid')
);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(
new RpcException('activities.0.symbol ("X") is not valid')
);
});
it('Hides the message of an unexpected error and writes it to the log', async () => {
setupUser([permissions.createActivity]);
const error = new Error(
'Unique constraint failed on the fields: (dataSource)'
);
const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
jest.spyOn(importService, 'import').mockRejectedValue(error);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(
new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR))
);
expect(logError).toHaveBeenCalledWith(error);
});
it('Does not write the message of a validation to the log', async () => {
setupUser([permissions.createActivity]);
const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation();
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new ImportValidationError('activities.0.accountId ("X") is not valid')
);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity({ accountId: 'X' })]
})
).rejects.toThrow(RpcException);
expect(logError).not.toHaveBeenCalled();
});
}); });
describe('Parameters of the tool to import activities', () => { // The tools have no try and catch, hence the filter is the only guarantee
function parse(activities: unknown[]) { // that an unexpected exception does not expose internals
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; it('Applies the filter of the exceptions of the tools', () => {
} expect(
Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, GhostfolioMcpController)
it('Refuses a currency in lower case', () => { ).toEqual([McpToolExceptionFilter]);
expect(parse([createActivity({ currency: 'usd' })])).toBe(false);
});
it('Accepts a currency in upper case', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
});
it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
});
it('Refuses an empty symbol', () => {
expect(parse([createActivity({ symbol: '' })])).toBe(false);
});
it('Refuses an empty identifier of an account', () => {
expect(parse([createActivity({ accountId: '' })])).toBe(false);
});
it('Removes a tag, because the tool takes no tag', () => {
expect(
IMPORT_ACTIVITIES_PARAMETERS.parse({
activities: [createActivity({ tags: ['tag-id'] })]
}).activities[0]
).not.toHaveProperty('tags');
});
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => {
expect(
parse(
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => {
return createActivity();
})
)
).toBe(false);
});
}); });
}); });

290
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -1,156 +1,27 @@
import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service';
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator';
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATE_RANGES,
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import {
isValidCurrencyCode,
isValidDateAfter1970
} from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes'; import { scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { HttpException, Logger, UseFilters } from '@nestjs/common'; import { UseFilters } from '@nestjs/common';
import { Payload, RpcException } from '@nestjs/microservices'; import { Payload } from '@nestjs/microservices';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { McpController, Tool } from '@rekog/mcp-nest'; import { McpController, Tool } from '@rekog/mcp-nest';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod'; import { z } from 'zod';
const GET_ACCOUNTS_PARAMETERS = z.object({ import {
accountIds: z GET_ACCOUNTS_PARAMETERS,
.array(z.string().min(1)) GET_ACTIVITIES_PARAMETERS,
.min(1) IMPORT_ACTIVITIES_PARAMETERS
.max(MCP_MAX_ACCOUNTS) } from './mcp.schemas';
.optional() import { McpService } from './mcp.service';
.describe(
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}`
),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the accounts to get'),
holding: z
.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
})
.optional()
.describe('The asset profile of the accounts to get')
});
const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z
.array(z.enum(ActivityType))
.min(1)
.optional()
.describe('The types of the activities to get'),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the activities to get'),
holding: z
.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
})
.optional()
.describe('The asset profile of the activities to get'),
range: z
.string()
.regex(DATE_RANGE_PATTERN)
.optional()
.describe(
`The date range of the activities to get, either ${DATE_RANGES.join(
', '
)} or a calendar year like 2024`
),
skip: z
.number()
.int()
.min(0)
.optional()
.describe('The number of activities to skip'),
take: z
.number()
.int()
.min(1)
.max(MCP_MAX_ACTIVITIES)
.optional()
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
});
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
activities: z
.array(
z.object({
accountId: z
.string()
.min(1)
.optional()
.describe('The identifier of the account of the activity'),
comment: z.string().optional().describe('The comment of the activity'),
currency: z
.string()
.refine(isValidCurrencyCode)
.describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
),
dataSource: z
.enum(DataSource)
.optional()
.describe('The data source of the asset profile'),
date: z
.string()
.refine(isValidDateAfter1970)
.describe(
'The date of the activity, as an ISO 8601 date or date and time'
),
fee: z.number().min(0).describe('The fee of the activity'),
quantity: z.number().min(0).describe('The quantity of the activity'),
symbol: z.string().min(1).describe('The symbol of the asset profile'),
type: z.enum(ActivityType).describe('The type of the activity'),
unitPrice: z.number().min(0).describe('The unit price of the activity')
})
)
.min(1)
.max(MCP_MAX_ACTIVITIES)
.describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`)
});
@McpController() @McpController()
@UseFilters(McpToolExceptionFilter) @UseFilters(McpToolExceptionFilter)
export class GhostfolioMcpController { export class GhostfolioMcpController {
private readonly logger = new Logger(GhostfolioMcpController.name); public constructor(private readonly mcpService: McpService) {}
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
private readonly userService: UserService
) {}
@RequiresScopeOfAccess(scopes.accountRead) @RequiresScopeOfAccess(scopes.accountRead)
@Tool({ @Tool({
@ -159,7 +30,7 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get accounts' title: 'Get accounts'
}, },
description: `Gives the accounts of the portfolio with these columns: ${AiService.getAccountsTableColumnNames().join( description: `Gives the accounts of the portfolio with these columns: ${PortfolioTableService.getAccountsTableColumnNames().join(
', ' ', '
)}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`, )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`,
name: 'get-accounts', name: 'get-accounts',
@ -167,23 +38,9 @@ export class GhostfolioMcpController {
}) })
public async getAccounts( public async getAccounts(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@Payload() @Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
{
accountIds,
assetClasses,
holding
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) { ) {
const filters = this.apiService.buildFiltersFromQueryParams({ return this.mcpService.getAccounts({ ...parameters, userId });
filterByAccounts: accountIds?.join(','),
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getAccountsTable({ filters, userId });
return { content: [{ text: table, type: 'text' as const }] };
} }
@RequiresScopeOfAccess(scopes.activityRead) @RequiresScopeOfAccess(scopes.activityRead)
@ -193,52 +50,21 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get activities' title: 'Get activities'
}, },
description: `Gives the activities of the portfolio, the most recent first, with these columns: ${AiService.getActivitiesTableColumnNames().join( description: `Gives the activities of the portfolio, the most recent first, with these columns: ${PortfolioTableService.getActivitiesTableColumnNames().join(
', ' ', '
)}. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, )}. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`,
name: 'get-activities', name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS parameters: GET_ACTIVITIES_PARAMETERS
}) })
public async getActivities( public async getActivities(
@Impersonation() @Impersonation() { userId, userSettings }: ImpersonationContext,
{ userId, userSettings }: ImpersonationContext, @Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
@Payload()
{
activityTypes,
assetClasses,
holding,
range,
skip,
take
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) { ) {
let endDate: Date | undefined; return this.mcpService.getActivities({
let startDate: Date | undefined; ...parameters,
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.aiService.getActivitiesTable({
endDate,
filters,
skip,
startDate,
userId, userId,
take: take ?? MCP_MAX_ACTIVITIES,
types: activityTypes,
userCurrency: userSettings.baseCurrency userCurrency: userSettings.baseCurrency
}); });
return { content: [{ text: table, type: 'text' as const }] };
} }
@RequiresScopeOfAccess(scopes.portfolioRead) @RequiresScopeOfAccess(scopes.portfolioRead)
@ -248,22 +74,13 @@ export class GhostfolioMcpController {
readOnlyHint: true, readOnlyHint: true,
title: 'Get portfolio' title: 'Get portfolio'
}, },
description: `Gives the holdings of the portfolio with these columns: ${AiService.getHoldingsTableColumnNames().join( description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join(
', ' ', '
)}.`, )}.`,
name: 'get-portfolio' name: 'get-portfolio'
}) })
public async getPortfolio( public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) {
@Impersonation() { userId, userSettings }: ImpersonationContext return this.mcpService.getPortfolio({ userId });
) {
const prompt = await this.aiService.getPrompt({
userId,
languageCode: DEFAULT_LANGUAGE_CODE,
mode: 'portfolio',
userCurrency: userSettings.baseCurrency
});
return { content: [{ text: prompt, type: 'text' as const }] };
} }
/** /**
@ -286,67 +103,8 @@ export class GhostfolioMcpController {
}) })
public async importActivities( public async importActivities(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@Payload() { activities }: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS> @Payload() parameters: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS>
) { ) {
const user = await this.userService.user({ id: userId }); return this.mcpService.importActivities({ ...parameters, userId });
if (!hasPermission(user?.permissions, permissions.createActivity)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
})
};
});
let importedActivities: Activity[];
try {
importedActivities = await this.importService.import({
activitiesDto,
user,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: []
});
} catch (error) {
// The message of a validation names the activity which is not valid and
// is written for the caller, hence it is passed on
if (error instanceof ImportValidationError) {
throw new RpcException(error.message);
}
// Every other message can carry internals of the application, hence it
// is written to the log and the reason phrase is passed on instead
this.logger.error(error);
throw new RpcException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)
);
}
const text = [
`Imported activities: ${importedActivities.length}`,
`Skipped duplicate activities: ${
activities.length - importedActivities.length
}`
].join('\n');
return { content: [{ text, type: 'text' as const }] };
} }
} }

12
apps/api/src/app/endpoints/mcp/mcp.module.ts

@ -1,10 +1,10 @@
import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module';
import { ImportModule } from '@ghostfolio/api/app/import/import.module'; import { ImportModule } from '@ghostfolio/api/app/import/import.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableModule } from '@ghostfolio/api/services/portfolio-table/portfolio-table.module';
import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -15,11 +15,19 @@ import {
} from '@rekog/mcp-nest'; } from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller'; import { GhostfolioMcpController } from './mcp.controller';
import { McpService } from './mcp.service';
@Module({ @Module({
controllers: [GhostfolioMcpController], controllers: [GhostfolioMcpController],
imports: [AiModule, ApiModule, ConfigurationModule, ImportModule, UserModule], imports: [
ApiModule,
ConfigurationModule,
ImportModule,
PortfolioTableModule,
UserModule
],
providers: [ providers: [
McpService,
{ {
inject: [ConfigurationService], inject: [ConfigurationService],
provide: MCP_STRATEGY, provide: MCP_STRATEGY,

48
apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts

@ -0,0 +1,48 @@
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { IMPORT_ACTIVITIES_PARAMETERS } from './mcp.schemas';
import { createActivity } from './mcp.test-utils';
describe('IMPORT_ACTIVITIES_PARAMETERS', () => {
function parse(activities: unknown[]) {
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success;
}
it('Refuses a currency in lower case', () => {
expect(parse([createActivity({ currency: 'usd' })])).toBe(false);
});
it('Accepts a currency in upper case', () => {
expect(parse([createActivity({ currency: 'USD' })])).toBe(true);
});
it('Refuses a date at or before the epoch', () => {
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false);
});
it('Refuses an empty symbol', () => {
expect(parse([createActivity({ symbol: '' })])).toBe(false);
});
it('Refuses an empty identifier of an account', () => {
expect(parse([createActivity({ accountId: '' })])).toBe(false);
});
it('Removes a tag, because the tool takes no tag', () => {
expect(
IMPORT_ACTIVITIES_PARAMETERS.parse({
activities: [{ ...createActivity(), tags: ['tag-id'] }]
}).activities[0]
).not.toHaveProperty('tags');
});
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => {
expect(
parse(
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => {
return createActivity();
})
)
).toBe(false);
});
});

115
apps/api/src/app/endpoints/mcp/mcp.schemas.ts

@ -0,0 +1,115 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import {
DATE_RANGES,
MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import {
isValidCurrencyCode,
isValidDateAfter1970
} from '@ghostfolio/common/helper';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { z } from 'zod';
const HOLDING_PARAMETER = z.object({
dataSource: z
.enum(DataSource)
.describe('The data source of the asset profile'),
symbol: z.string().describe('The symbol of the asset profile')
});
export const GET_ACCOUNTS_PARAMETERS = z.object({
accountIds: z
.array(z.string().min(1))
.min(1)
.max(MCP_MAX_ACCOUNTS)
.optional()
.describe(
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}`
),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the accounts to get'),
holding: HOLDING_PARAMETER.optional().describe(
'The asset profile of the accounts to get'
)
});
export const GET_ACTIVITIES_PARAMETERS = z.object({
activityTypes: z
.array(z.enum(ActivityType))
.min(1)
.optional()
.describe('The types of the activities to get'),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
.optional()
.describe('The asset classes of the activities to get'),
holding: HOLDING_PARAMETER.optional().describe(
'The asset profile of the activities to get'
),
range: z
.string()
.regex(DATE_RANGE_PATTERN)
.optional()
.describe(
`The date range of the activities to get, either ${DATE_RANGES.join(
', '
)} or a calendar year like 2024`
),
skip: z
.number()
.int()
.min(0)
.optional()
.describe('The number of activities to skip'),
take: z
.number()
.int()
.min(1)
.max(MCP_MAX_ACTIVITIES)
.default(MCP_MAX_ACTIVITIES)
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
});
export const IMPORT_ACTIVITIES_PARAMETERS = z.object({
activities: z
.array(
z.object({
accountId: z
.string()
.min(1)
.optional()
.describe('The identifier of the account of the activity'),
comment: z.string().optional().describe('The comment of the activity'),
currency: z
.string()
.refine(isValidCurrencyCode)
.describe(
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
),
dataSource: z
.enum(DataSource)
.optional()
.describe('The data source of the asset profile'),
date: z
.string()
.refine(isValidDateAfter1970)
.describe(
'The date of the activity, as an ISO 8601 date or date and time'
),
fee: z.number().min(0).describe('The fee of the activity'),
quantity: z.number().min(0).describe('The quantity of the activity'),
symbol: z.string().min(1).describe('The symbol of the asset profile'),
type: z.enum(ActivityType).describe('The type of the activity'),
unitPrice: z.number().min(0).describe('The unit price of the activity')
})
)
.min(1)
.max(MCP_MAX_ACTIVITIES)
.describe(`The activities to import, at most ${MCP_MAX_ACTIVITIES}`)
});

279
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -0,0 +1,279 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import {
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { UserWithSettings } from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { McpService } from './mcp.service';
import { createActivity } from './mcp.test-utils';
describe('McpService', () => {
const filters: Filter[] = [{ id: 'account-id', type: 'ACCOUNT' }];
const userCurrency = 'USD';
const userId = 'user-id';
let apiService: ApiService;
let configuration: Record<string, unknown>;
let configurationService: ConfigurationService;
let importService: ImportService;
let mcpService: McpService;
let portfolioTableService: PortfolioTableService;
let userService: UserService;
function setupUser(userPermissions: string[]) {
jest.spyOn(userService, 'user').mockResolvedValue({
permissions: userPermissions
} as UserWithSettings);
}
beforeEach(() => {
configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false
};
configurationService = {
get: jest.fn().mockImplementation((key: string) => {
return configuration[key];
})
} as unknown as ConfigurationService;
apiService = {
buildFiltersFromQueryParams: jest.fn().mockReturnValue(filters)
} as unknown as ApiService;
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
portfolioTableService = {
getAccountsTable: jest.fn().mockResolvedValue('## Accounts'),
getActivitiesTable: jest.fn().mockResolvedValue('## Activities'),
getHoldingsTable: jest.fn().mockResolvedValue('## Holdings')
} as unknown as PortfolioTableService;
userService = { user: jest.fn() } as unknown as UserService;
mcpService = new McpService(
apiService,
configurationService,
importService,
portfolioTableService,
userService
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getAccounts', () => {
it('Maps the parameters of the tool to the filters', async () => {
await mcpService.getAccounts({
userId,
accountIds: ['account-id'],
assetClasses: [AssetClass.EQUITY],
holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({
filterByAccounts: ['account-id'],
filterByAssetClasses: [AssetClass.EQUITY],
filterByDataSource: DataSource.YAHOO,
filterBySymbol: 'AAPL'
});
});
it('Gives the table of the accounts of the filters', async () => {
expect(await mcpService.getAccounts({ userId })).toEqual({
content: [{ text: '## Accounts', type: 'text' }]
});
expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith({
filters,
userId
});
});
});
describe('getActivities', () => {
function getActivities(
parameters: Partial<Parameters<McpService['getActivities']>[0]> = {}
) {
return mcpService.getActivities({
userCurrency,
userId,
take: MCP_MAX_ACTIVITIES,
...parameters
});
}
it('Maps the parameters of the tool to the filters', async () => {
await getActivities({
assetClasses: [AssetClass.EQUITY],
holding: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }
});
expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith({
filterByAssetClasses: [AssetClass.EQUITY],
filterByDataSource: DataSource.YAHOO,
filterBySymbol: 'AAPL'
});
});
it('Changes the range into the start date and the end date', async () => {
await getActivities({ range: '2024' });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({
endDate: new Date('2024-12-31T23:59:59.999Z'),
startDate: new Date('2023-12-31T23:59:59.999Z')
})
);
});
it('Gives no date if the range is absent', async () => {
await getActivities();
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({ endDate: undefined, startDate: undefined })
);
});
it('Gives the table of the activities of the filters', async () => {
expect(
await getActivities({ activityTypes: [ActivityType.BUY], skip: 10 })
).toEqual({ content: [{ text: '## Activities', type: 'text' }] });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith({
filters,
userCurrency,
userId,
endDate: undefined,
skip: 10,
startDate: undefined,
take: MCP_MAX_ACTIVITIES,
types: [ActivityType.BUY]
});
});
});
describe('getPortfolio', () => {
it('Gives the table of the holdings in the default language', async () => {
expect(await mcpService.getPortfolio({ userId })).toEqual({
content: [{ text: '## Holdings', type: 'text' }]
});
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
});
});
});
describe('importActivities', () => {
it('Refuses a user without the permission to create an activity', async () => {
setupUser([]);
await expect(
mcpService.importActivities({
userId,
activities: [createActivity()]
})
).rejects.toThrow(HttpException);
expect(importService.import).not.toHaveBeenCalled();
});
it('Gives the number of the imported and of the skipped activities', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockResolvedValue([{ id: 'activity-id' } as Activity]);
expect(
await mcpService.importActivities({
userId,
activities: [createActivity(), createActivity({ quantity: 2 })]
})
).toEqual({
content: [
{
text: 'Imported activities: 1\nSkipped duplicate activities: 1',
type: 'text'
}
]
});
});
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = true;
await mcpService.importActivities({
userId,
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
it('Keeps the data source if the subscription is not enabled', async () => {
setupUser([permissions.createActivity]);
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO];
configuration.ENABLE_FEATURE_SUBSCRIPTION = false;
await mcpService.importActivities({
userId,
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO })
]
})
);
});
// The McpToolExceptionFilter maps the error, hence the tool passes it on
it('Passes on an error of the import', async () => {
setupUser([permissions.createActivity]);
const error = new ImportValidationError(
'activities.0.symbol ("X") is not valid'
);
jest.spyOn(importService, 'import').mockRejectedValue(error);
await expect(
mcpService.importActivities({
userId,
activities: [createActivity()]
})
).rejects.toBe(error);
});
});
});

176
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -0,0 +1,176 @@
import { ImportService } from '@ghostfolio/api/app/import/import.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod';
import {
GET_ACCOUNTS_PARAMETERS,
GET_ACTIVITIES_PARAMETERS,
IMPORT_ACTIVITIES_PARAMETERS
} from './mcp.schemas';
@Injectable()
export class McpService {
public constructor(
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
private readonly portfolioTableService: PortfolioTableService,
private readonly userService: UserService
) {}
public async getAccounts({
accountIds,
assetClasses,
holding,
userId
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & { userId: string }) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds,
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.portfolioTableService.getAccountsTable({
filters,
userId
});
return this.getTextResult(table);
}
public async getActivities({
activityTypes,
assetClasses,
holding,
range,
skip,
take,
userCurrency,
userId
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & {
userCurrency: string;
userId: string;
}) {
let endDate: Date | undefined;
let startDate: Date | undefined;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
const table = await this.portfolioTableService.getActivitiesTable({
endDate,
filters,
skip,
startDate,
take,
userCurrency,
userId,
types: activityTypes
});
return this.getTextResult(table);
}
public async getPortfolio({ userId }: { userId: string }) {
const table = await this.portfolioTableService.getHoldingsTable({
userId,
languageCode: DEFAULT_LANGUAGE_CODE
});
return this.getTextResult(table);
}
public async importActivities({
activities,
userId
}: z.infer<typeof IMPORT_ACTIVITIES_PARAMETERS> & { userId: string }) {
const user = await this.getUserWithPermission({
userId,
permission: permissions.createActivity
});
const ghostfolioDataSources = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
: [];
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
})
};
});
// The filter passes on the message of a CallerFacingError, which is
// written for the caller, and hides the message of every other error
const importedActivities = await this.importService.import({
activitiesDto,
user,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: []
});
const text = [
`Imported activities: ${importedActivities.length}`,
`Skipped duplicate activities: ${
activities.length - importedActivities.length
}`
].join('\n');
return this.getTextResult(text);
}
private getTextResult(text: string) {
return { content: [{ text, type: 'text' as const }] };
}
/**
* Gives the user of the access, if the role of the user has the permission.
* The scope of the access is evaluated separately by the ScopeGuard, hence a
* tool which changes data has to call this.
*/
private async getUserWithPermission({
permission,
userId
}: {
permission: string;
userId: string;
}) {
const user = await this.userService.user({ id: userId });
if (!hasPermission(user?.permissions, permission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return user;
}
}

18
apps/api/src/app/endpoints/mcp/mcp.test-utils.ts

@ -0,0 +1,18 @@
import { Type as ActivityType } from '@prisma/client';
import { ActivityToImport } from './types/activity-to-import.type';
export function createActivity(
overrides: Partial<ActivityToImport> = {}
): ActivityToImport {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}

7
apps/api/src/app/endpoints/mcp/types/activity-to-import.type.ts

@ -0,0 +1,7 @@
import { z } from 'zod';
import { IMPORT_ACTIVITIES_PARAMETERS } from '../mcp.schemas';
export type ActivityToImport = z.infer<
typeof IMPORT_ACTIVITIES_PARAMETERS
>['activities'][number];

4
apps/api/src/app/import/errors/import-validation.error.ts

@ -1,4 +1,6 @@
export class ImportValidationError extends Error { import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
export class ImportValidationError extends CallerFacingError {
public constructor(message: string) { public constructor(message: string) {
super(message); super(message);

4
apps/api/src/app/portfolio/get-holdings.dto.ts

@ -1,7 +1,8 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { HoldingType } from '@ghostfolio/common/types'; import { HoldingType } from '@ghostfolio/common/types';
import { IsIn, IsOptional, IsString } from 'class-validator'; import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
export class GetHoldingsDto extends DateRangeFilterDto { export class GetHoldingsDto extends DateRangeFilterDto {
@IsIn(['ACTIVE', 'CLOSED'] as HoldingType[]) @IsIn(['ACTIVE', 'CLOSED'] as HoldingType[])
@ -10,5 +11,6 @@ export class GetHoldingsDto extends DateRangeFilterDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query?: string; query?: string;
} }

16
apps/api/src/app/symbol/get-lookup.dto.ts

@ -0,0 +1,16 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsString, MaxLength } from 'class-validator';
export class GetLookupDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
includeIndices? = false;
@IsString()
@MaxLength(SEARCH_QUERY_MAXIMUM_LENGTH)
query? = '';
}

6
apps/api/src/app/symbol/symbol.controller.ts

@ -27,6 +27,7 @@ import { isValid, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { GetLookupDto } from './get-lookup.dto';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Controller('symbol') @Controller('symbol')
@ -43,11 +44,8 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false', @Query() { includeIndices, query }: GetLookupDto
@Query('query') query = ''
): Promise<LookupResponse> { ): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
try { try {
return this.symbolService.lookup({ return this.symbolService.lookup({
includeIndices, includeIndices,

4
apps/api/src/app/user/user.service.ts

@ -275,7 +275,7 @@ export class UserService {
} }
try { try {
const { isBlocked } = await this.throttlerStorage.increment( const { totalHits } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`, `${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL, THROTTLE_DAILY_TTL,
maxDailyRequests, maxDailyRequests,
@ -283,7 +283,7 @@ export class UserService {
THROTTLE_DAILY_KEY THROTTLE_DAILY_KEY
); );
return isBlocked; return totalHits > maxDailyRequests;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);

39
apps/api/src/dtos/filter.dto.ts

@ -1,23 +1,44 @@
import { IsOptional, IsString } from 'class-validator'; import { SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { AssetClass, DataSource } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength
} from 'class-validator';
import { isString } from 'lodash';
export class FilterDto { export class FilterDto {
@IsOptional() @IsOptional()
@IsString() @IsUUID(undefined, { each: true })
accounts?: string; @Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
accounts?: string[];
@IsEnum(AssetClass, { each: true })
@IsOptional() @IsOptional()
@IsString() @Transform(({ value }: TransformFnParams) => {
assetClasses?: string; return isString(value) ? value.split(',') : value;
})
assetClasses?: AssetClass[];
@IsEnum(DataSource)
@IsOptional() @IsOptional()
@IsString() dataSource?: DataSource;
dataSource?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol?: string; symbol?: string;
@IsOptional() @IsOptional()
@IsString() @IsUUID(undefined, { each: true })
tags?: string; @Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
tags?: string[];
} }

12
apps/api/src/errors/caller-facing.error.ts

@ -0,0 +1,12 @@
/**
* An error whose message is written for the caller. A filter passes such a
* message on, while it hides the message of every other error, because that
* message can carry internals of the application.
*/
export class CallerFacingError extends Error {
public constructor(message: string) {
super(message);
this.name = 'CallerFacingError';
}
}

83
apps/api/src/filters/mcp-tool-exception.filter.spec.ts

@ -0,0 +1,83 @@
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error';
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { ForbiddenException, Logger } from '@nestjs/common';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { firstValueFrom } from 'rxjs';
import { McpToolExceptionFilter } from './mcp-tool-exception.filter';
describe('McpToolExceptionFilter', () => {
let filter: McpToolExceptionFilter;
let logError: jest.SpyInstance;
async function getErrorOfException(exception: unknown) {
try {
await firstValueFrom(filter.catch(exception));
} catch (error) {
return error;
}
throw new Error('The filter gave no error');
}
beforeEach(() => {
filter = new McpToolExceptionFilter();
logError = jest.spyOn(Logger.prototype, 'error').mockImplementation();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('Passes on the message of an error which is written for the caller', async () => {
const exception = new ImportValidationError(
'activities.0.symbol ("X") is not valid'
);
expect(await getErrorOfException(exception)).toEqual({
message: 'activities.0.symbol ("X") is not valid',
status: 'error'
});
expect(logError).not.toHaveBeenCalled();
});
it('Hides the message of an unexpected error and writes it to the log', async () => {
const exception = new Error(
'Unique constraint failed on the fields: (dataSource)'
);
expect(await getErrorOfException(exception)).toEqual({
message: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
status: 'error'
});
expect(logError).toHaveBeenCalledWith(exception);
});
// An access without the scope of a tool causes a refused call at each
// attempt, which would fill the log
it('Gives the reason phrase of the status of an HttpException and writes no log', async () => {
expect(await getErrorOfException(new ForbiddenException())).toEqual({
message: getReasonPhrase(StatusCodes.FORBIDDEN),
status: 'error'
});
expect(logError).not.toHaveBeenCalled();
});
it('Gives the reason phrase of a service which is not available if a snapshot cannot be computed', async () => {
const exception = new PortfolioSnapshotComputationError(
'The snapshot cannot be computed'
);
expect(await getErrorOfException(exception)).toEqual({
message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
status: 'error'
});
expect(logError).toHaveBeenCalledWith(exception);
});
});

30
apps/api/src/filters/mcp-tool-exception.filter.ts

@ -1,4 +1,5 @@
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
import { import {
Catch, Catch,
@ -6,7 +7,6 @@ import {
Logger, Logger,
RpcExceptionFilter RpcExceptionFilter
} from '@nestjs/common'; } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
@ -20,24 +20,32 @@ export class McpToolExceptionFilter implements RpcExceptionFilter {
private readonly logger = new Logger(McpToolExceptionFilter.name); private readonly logger = new Logger(McpToolExceptionFilter.name);
public catch(exception: unknown): Observable<never> { public catch(exception: unknown): Observable<never> {
this.logger.error(exception); // The message of this exception is written for the caller, hence it is
// passed on and is not written to the log
if (exception instanceof RpcException) { if (exception instanceof CallerFacingError) {
return throwError(() => { return throwError(() => {
return exception.getError(); return { message: exception.message, status: 'error' };
}); });
} }
return throwError(() => { const statusCode = this.getStatus(exception);
return { message: this.getMessage(exception), status: 'error' };
}); // An exception which the caller causes, for example a refused call, is
} // expected, hence only an exception of the application is written to the
// log
if (statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) {
this.logger.error(exception);
}
private getMessage(exception: unknown) {
// The message of an exception can carry internals, for example the // The message of an exception can carry internals, for example the
// property names of a data transfer object of a failed validation, hence // property names of a data transfer object of a failed validation, hence
// the reason phrase of the status is passed on instead // the reason phrase of the status is passed on instead
return this.getReasonPhraseOfStatus(this.getStatus(exception)); return throwError(() => {
return {
message: this.getReasonPhraseOfStatus(statusCode),
status: 'error'
};
});
} }
private getReasonPhraseOfStatus(statusCode: number) { private getReasonPhraseOfStatus(statusCode: number) {

6
apps/api/src/helper/interfaces/table-column-definition.interface.ts

@ -0,0 +1,6 @@
import type { ColumnDescriptor } from 'tablemark';
export interface TableColumnDefinition<T, C = void> extends ColumnDescriptor {
getValue: (item: T, context: C) => string;
name: string;
}

7
apps/api/src/helper/interfaces/table-parameters.interface.ts

@ -0,0 +1,7 @@
import { TableColumnDefinition } from './table-column-definition.interface';
export interface TableParameters<T, C> {
columnDefinitions: readonly TableColumnDefinition<T, C>[];
context?: C;
rows: readonly T[];
}

77
apps/api/src/helper/markdown-table.helper.spec.ts

@ -0,0 +1,77 @@
import { TableColumnDefinition } from './interfaces/table-column-definition.interface';
import { getTableInput } from './markdown-table.helper';
interface Holding {
name: string;
quantity: number;
}
interface HoldingContext {
currency: string;
}
describe('getTableInput', () => {
const holdings: Holding[] = [
{ name: 'Apple', quantity: 2 },
{ name: 'Microsoft', quantity: 30 }
];
const columnDefinitions: TableColumnDefinition<Holding, HoldingContext>[] = [
{
getValue: ({ name }) => {
return name;
},
name: 'Name'
},
{
align: 'right',
getValue: ({ quantity }) => {
return quantity.toString();
},
name: 'Quantity'
},
{
getValue: (_, { currency }) => {
return currency;
},
name: 'Currency'
}
];
function getInput(rows: Holding[] = holdings) {
return getTableInput({
columnDefinitions,
rows,
context: { currency: 'USD' }
});
}
it('Gives a column for each definition, in the sequence of the definitions', () => {
expect(getInput().columns).toEqual([
{ align: 'left', name: 'Name' },
{ align: 'right', name: 'Quantity' },
{ align: 'left', name: 'Currency' }
]);
});
it('Gives a row for each row, with the name of the column as the key', () => {
expect(getInput().rows).toEqual([
{ Currency: 'USD', Name: 'Apple', Quantity: '2' },
{ Currency: 'USD', Name: 'Microsoft', Quantity: '30' }
]);
});
it('Gives the keys of a row in the sequence of the columns', () => {
const [firstRow] = getInput().rows;
expect(Object.keys(firstRow)).toEqual(
getInput().columns.map(({ name }) => {
return name;
})
);
});
it('Gives no row if there is no row', () => {
expect(getInput([]).rows).toEqual([]);
});
});

44
apps/api/src/helper/markdown-table.helper.ts

@ -0,0 +1,44 @@
import { TableParameters } from './interfaces/table-parameters.interface';
/**
* Gives the columns and the rows in the form which the renderer takes, with
* one column per definition, which gives the title of the column and reads
* the value of a row
*/
export function getTableInput<T, C = void>({
columnDefinitions,
context,
rows
}: TableParameters<T, C>) {
return {
columns: columnDefinitions.map(({ align, name }) => {
return { name, align: align ?? 'left' };
}),
rows: rows.map((row) => {
return columnDefinitions.reduce(
(tableRow, { getValue, name }) => {
tableRow[name] = getValue(row, context);
return tableRow;
},
{} as Record<string, string>
);
})
};
}
export async function getMarkdownTable<T, C = void>(
parameters: TableParameters<T, C>
) {
const { columns, rows } = getTableInput(parameters);
// Dynamic import to load ESM module from CommonJS context
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const dynamicImport = new Function('s', 'return import(s)') as (
s: string
) => Promise<typeof import('tablemark')>;
const { tablemark } = await dynamicImport('tablemark');
return tablemark(rows, { columns });
}

18
apps/api/src/services/api/api.service.ts

@ -14,23 +14,23 @@ export class ApiService {
filterBySymbol, filterBySymbol,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string[];
filterByAssetClasses?: string; filterByAssetClasses?: string[];
filterByAssetSubClasses?: string; filterByAssetSubClasses?: string;
filterByDataSource?: string; filterByDataSource?: string;
filterByHoldingType?: string; filterByHoldingType?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterBySymbol?: string; filterBySymbol?: string;
filterByTags?: string; filterByTags?: string[];
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const dataSource = filterByDataSource; const dataSource = filterByDataSource;
const holdingType = filterByHoldingType; const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const symbol = filterBySymbol; const symbol = filterBySymbol;
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags ?? [];
const filters = [ const filters = [
...accountIds.map((accountId) => { ...accountIds.map((accountId) => {
@ -96,11 +96,11 @@ export class ApiService {
userSettings: UserSettings; userSettings: UserSettings;
}): Filter[] { }): Filter[] {
return this.buildFiltersFromQueryParams({ return this.buildFiltersFromQueryParams({
filterByAccounts: userSettings?.['filters.accounts']?.[0], filterByAccounts: userSettings?.['filters.accounts'],
filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0], filterByAssetClasses: userSettings?.['filters.assetClasses'],
filterByDataSource: userSettings?.['filters.dataSource'], filterByDataSource: userSettings?.['filters.dataSource'],
filterBySymbol: userSettings?.['filters.symbol'], filterBySymbol: userSettings?.['filters.symbol'],
filterByTags: userSettings?.['filters.tags']?.[0] filterByTags: userSettings?.['filters.tags']
}); });
} }
} }

2
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -54,6 +54,8 @@ export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit implements DataProviderInterface, OnModuleInit
{ {
private static countriesMapping = { private static countriesMapping = {
'Congo (Dem. Rep. of the)': 'CD',
'Congo (Rep. of)': 'CG',
'Czech Republic': 'CZ', 'Czech Republic': 'CZ',
'Korea (the Republic of)': 'KR', 'Korea (the Republic of)': 'KR',
Macau: 'MO', Macau: 'MO',

6
apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts

@ -0,0 +1,6 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface HoldingsTableContext {
assetClassTranslations: Record<AssetClass, string>;
assetSubClassTranslations: Record<AssetSubClass, string>;
}

14
apps/api/src/services/portfolio-table/portfolio-table.module.ts

@ -0,0 +1,14 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common';
import { PortfolioTableService } from './portfolio-table.service';
@Module({
exports: [PortfolioTableService],
imports: [ActivitiesModule, I18nModule, PortfolioModule],
providers: [PortfolioTableService]
})
export class PortfolioTableModule {}

272
apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts

@ -0,0 +1,272 @@
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface';
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { PortfolioTableService } from './portfolio-table.service';
/**
* The markdown table is rendered by a package which ships as an ECMAScript
* module only, which Jest cannot run. The mock keeps the mapping of the
* columns and of the rows and writes them in the same shape as the renderer
*/
jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => {
const { getTableInput } = jest.requireActual<
typeof import('@ghostfolio/api/helper/markdown-table.helper')
>('@ghostfolio/api/helper/markdown-table.helper');
return {
getTableInput,
getMarkdownTable: jest.fn(
(parameters: TableParameters<unknown, unknown>) => {
const { columns, rows } = getTableInput(parameters);
const names = columns.map(({ name }) => {
return name;
});
return Promise.resolve(
[
names,
names.map(() => {
return '---';
}),
...rows.map((row) => {
return names.map((name) => {
return row[name];
});
})
]
.map((cells) => {
return `| ${cells.join(' | ')} |`;
})
.join('\n')
);
}
)
};
});
function createAccount({
id = 'account-a-id',
isExcluded = false,
name = 'Account A'
}: {
id?: string;
isExcluded?: boolean;
name?: string;
} = {}) {
return {
id,
name,
activitiesCount: 3,
allocationInPercentage: 0.25,
balance: 1000,
currency: 'CHF',
platform: { name: 'Platform A' },
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [],
value: 2000
} as unknown as AccountWithValue;
}
function createHolding({
allocationInPercentage = 0.75,
assetClass = AssetClass.EQUITY,
assetSubClass = AssetSubClass.STOCK,
symbol = 'AAPL'
}: {
allocationInPercentage?: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
symbol?: string;
} = {}) {
return {
allocationInPercentage,
activitiesCount: 3,
assetProfile: {
assetClass,
assetSubClass,
symbol,
currency: 'CHF',
name: `Name of ${symbol}`
},
dateOfFirstActivity: new Date('2024-01-01'),
grossPerformance: 100,
netPerformance: 90,
quantity: 5,
valueInBaseCurrency: 2000
} as unknown as PortfolioPosition;
}
function createPortfolioTableService({
accounts = [],
holdings = []
}: {
accounts?: AccountWithValue[];
holdings?: PortfolioPosition[];
} = {}) {
// The mock gives the identifier of the translation, so that a test can tell
// the translation of the asset class from that of the asset sub class
const i18nService = {
getTranslation: jest.fn(({ id }: { id: string }) => {
return `translation of ${id}`;
})
} as unknown as I18nService;
const portfolioService = {
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }),
getDetails: jest.fn().mockResolvedValue({ holdings })
} as unknown as PortfolioService;
return new PortfolioTableService(null, i18nService, portfolioService);
}
describe('PortfolioTableService', () => {
// The tools of the model context protocol are the only callers, and an
// access of that type never grants the scope to read the monetary values,
// hence no table has a column with such a value
describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([
'Id',
'Name',
'Currency',
'Platform',
'Activities Count',
'Allocation in Percentage',
'Excluded from Analysis'
]);
});
});
describe('getActivitiesTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([
'Date',
'Type',
'Name',
'Symbol',
'Currency',
'Unit Price',
'Account'
]);
});
});
describe('getHoldingsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
'Name',
'Symbol',
'Currency',
'Asset Class',
'Asset Sub Class',
'Date of First Activity',
'Activities Count',
'Allocation in Percentage'
]);
});
});
describe('getAccountsTable', () => {
it('gives no cash balance and no value of an account', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
expect(result).not.toContain('Cash Balance');
expect(result).not.toContain('1000');
expect(result).not.toContain('2000');
});
// The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them
it('gives the identifier of an account', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
expect(result).toContain('account-a-id');
});
it('marks an account which is excluded from the analysis', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [
createAccount({ isExcluded: true }),
createAccount({ id: 'account-b-id', name: 'Account B' })
]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
const [rowOfAccountA, rowOfAccountB] = result
.split('\n')
.filter((line) => {
return line.startsWith('| account-');
});
expect(rowOfAccountA).toContain('true');
expect(rowOfAccountB).toContain('false');
});
it('tells that no accounts are found if the result is empty', async () => {
const portfolioTableService = createPortfolioTableService();
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id'
});
expect(result).toContain('No accounts found.');
});
});
describe('getHoldingsTable', () => {
function getHoldingsTable(holdings: PortfolioPosition[]) {
return createPortfolioTableService({ holdings }).getHoldingsTable({
languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id'
});
}
it('gives the translation of the asset class and of the asset sub class', async () => {
const result = await getHoldingsTable([createHolding()]);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toContain('translation of assetClass.EQUITY');
expect(row).toContain('translation of assetSubClass.STOCK');
});
it('gives the holding with the largest allocation first', async () => {
const result = await getHoldingsTable([
createHolding({ allocationInPercentage: 0.25, symbol: 'MSFT' }),
createHolding({ allocationInPercentage: 0.75, symbol: 'AAPL' })
]);
const [firstRow, secondRow] = result.split('\n').filter((line) => {
return line.startsWith('| Name of');
});
expect(firstRow).toContain('AAPL');
expect(secondRow).toContain('MSFT');
});
});
});

399
apps/api/src/services/portfolio-table/portfolio-table.service.ts

@ -0,0 +1,399 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { AccountWithValue } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { format } from 'date-fns';
import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type';
function getAllocationInPercentage(allocationInPercentage: number) {
return `${(allocationInPercentage * 100).toFixed(3)}%`;
}
/**
* Renders the accounts, the activities and the holdings of a portfolio as a
* markdown table. No table has a column with a quantity or with a monetary
* value, except the unit price of an activity.
*/
@Injectable()
export class PortfolioTableService {
private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<AccountWithValue>[] =
[
{
getValue: ({ id }) => {
return id;
},
name: 'Id'
},
{
getValue: ({ name }) => {
return name ?? '';
},
name: 'Name'
},
{
getValue: ({ currency }) => {
return currency ?? '';
},
name: 'Currency'
},
{
getValue: ({ platform }) => {
return platform?.name ?? '';
},
name: 'Platform'
},
{
align: 'right',
getValue: ({ activitiesCount }) => {
return activitiesCount.toString();
},
name: 'Activities Count'
},
{
align: 'right',
getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage);
},
name: 'Allocation in Percentage'
},
{
getValue: ({ tags }) => {
return isAccountExcluded({ tags }).toString();
},
name: 'Excluded from Analysis'
}
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<Activity>[] =
[
{
getValue: ({ date }) => {
return format(date, DATE_FORMAT);
},
name: 'Date'
},
{
getValue: ({ type }) => {
return type;
},
name: 'Type'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.name ?? '';
},
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
},
{
getValue: ({ assetProfile, currency }) => {
return currency ?? assetProfile.currency;
},
name: 'Currency'
},
{
align: 'right',
getValue: ({ unitPrice }) => {
return unitPrice.toString();
},
name: 'Unit Price'
},
{
getValue: ({ account }) => {
return account?.name ?? '';
},
name: 'Account'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[
{
getValue: ({ assetProfile }) => {
return assetProfile.name;
},
name: 'Name'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.symbol;
},
name: 'Symbol'
},
{
getValue: ({ assetProfile }) => {
return assetProfile.currency;
},
name: 'Currency'
},
{
getValue: ({ assetProfile }, { assetClassTranslations }) => {
return assetClassTranslations[assetProfile.assetClass] ?? '';
},
name: 'Asset Class'
},
{
getValue: ({ assetProfile }, { assetSubClassTranslations }) => {
return assetSubClassTranslations[assetProfile.assetSubClass] ?? '';
},
name: 'Asset Sub Class'
},
{
getValue: ({ dateOfFirstActivity }) => {
return dateOfFirstActivity
? format(dateOfFirstActivity, DATE_FORMAT)
: '';
},
name: 'Date of First Activity'
},
{
align: 'right',
getValue: ({ activitiesCount }) => {
return activitiesCount.toString();
},
name: 'Activities Count'
},
{
align: 'right',
getValue: ({ allocationInPercentage }) => {
return getAllocationInPercentage(allocationInPercentage);
},
name: 'Allocation in Percentage'
}
];
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly i18nService: I18nService,
private readonly portfolioService: PortfolioService
) {}
public static getAccountsTableColumnNames() {
return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getActivitiesTableColumnNames() {
return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getHoldingsTableColumnNames() {
return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public async getAccountsTable({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}) {
const { accounts } =
await this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
});
const accountsSection = ['## Accounts', ''];
if (accounts.length > 0) {
accountsSection.push(
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
rows: accounts
})
);
} else {
accountsSection.push('No accounts found.');
}
return accountsSection.join('\n');
}
public async getActivitiesTable({
endDate,
filters,
skip = 0,
startDate,
take,
types,
userCurrency,
userId
}: {
endDate?: Date;
filters?: Filter[];
skip?: number;
startDate?: Date;
take: number;
types?: ActivityType[];
userCurrency: string;
userId: string;
}) {
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
startDate,
take,
types,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'desc',
withExcludedAccountsAndActivities: true
});
const activitiesSection = [
'## Activities',
'',
this.getActivitiesSummary({
count,
skip,
numberOfActivities: activities.length
})
];
if (activities.length > 0) {
activitiesSection.push(
'',
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
rows: activities
})
);
}
return activitiesSection.join('\n');
}
public async getHoldingsTable({
filters,
languageCode,
userId
}: {
filters?: Filter[];
languageCode: string;
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
userId
});
const assetClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetClass',
values: Object.values(AssetClass)
});
const assetSubClassTranslations = this.getEnumTranslations({
languageCode,
id: 'assetSubClass',
values: Object.values(AssetSubClass)
});
const sortedHoldings = [...holdings].sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
});
return [
'## Holdings',
'',
await getMarkdownTable({
columnDefinitions:
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
context: { assetClassTranslations, assetSubClassTranslations },
rows: sortedHoldings
})
].join('\n');
}
private getActivitiesSummary({
count,
numberOfActivities,
skip
}: {
count: number;
numberOfActivities: number;
skip: number;
}) {
if (count === 0) {
return 'No activities found.';
}
if (numberOfActivities === 0) {
return `No activities beyond the ${count} which match the parameters, hence lower the skip parameter.`;
}
if (numberOfActivities === count) {
return `Showing all ${count} activities, the most recent first.`;
}
const lastActivity = skip + numberOfActivities;
const summary = `Showing the activities ${
skip + 1
} to ${lastActivity} of ${count}, the most recent first.`;
if (lastActivity === count) {
return summary;
}
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`;
}
private getEnumTranslations<T extends string>({
id,
languageCode,
values
}: {
id: string;
languageCode: string;
values: T[];
}) {
return values.reduce(
(translations, value) => {
translations[value] =
this.i18nService.getTranslation({
languageCode,
id: `${id}.${value}`
}) || value;
return translations;
},
{} as Record<T, string>
);
}
}

9
apps/api/src/services/portfolio-table/types/holdings-table-column-definition.type.ts

@ -0,0 +1,9 @@
import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { HoldingsTableContext } from '../interfaces/holdings-table-context.interface';
export type HoldingsTableColumnDefinition = TableColumnDefinition<
PortfolioPosition,
HoldingsTableContext
>;

8
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -82,9 +82,7 @@ export class PortfolioSnapshotProcessor {
const expiration = addMilliseconds( const expiration = addMilliseconds(
new Date(), new Date(),
(snapshot?.errors?.length ?? 0) === 0 this.configurationService.get('CACHE_QUOTES_TTL')
? this.configurationService.get('CACHE_QUOTES_TTL')
: 0
); );
await this.redisCacheService.set( await this.redisCacheService.set(
@ -101,7 +99,9 @@ export class PortfolioSnapshotProcessor {
return snapshot; return snapshot;
} catch (error) { } catch (error) {
this.logger.error(error.message); this.logger.error(
`Portfolio snapshot calculation of user '${job.data.userId}' has failed: ${error.message}`
);
throw new Error(error); throw new Error(error);
} }

38
apps/client/src/app/components/access-table/access-table.component.html

@ -45,36 +45,6 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.type === 'PUBLIC') {
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline" />
<a target="_blank" [href]="getPublicUrl(element.id)">{{
getPublicUrl(element.id)
}}</a>
</div>
@if (user()?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
element.id
}}/portfolio</code
>
</div>
}
} @else if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<div>
<code>{{ baseUrl }}{{ mcpEndpoint }}</code>
</div>
<div>
<code>Authorization: Bearer {{ element.id }}</code>
</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-right" mat-header-cell></th>
@ -113,14 +83,6 @@
</span> </span>
</button> </button>
} }
@if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<button mat-menu-item (click)="onCopyIdToClipboard(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span i18n>Copy identifier to clipboard</span>
</span>
</button>
}
@if ( @if (
(!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) || (!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) ||
element.type === 'PUBLIC' || element.type === 'PUBLIC' ||

26
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,4 +1,3 @@
import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
@ -32,7 +31,6 @@ import {
copyOutline, copyOutline,
createOutline, createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline,
removeCircleOutline, removeCircleOutline,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
@ -81,14 +79,7 @@ export class GfAccessTableComponent {
protected readonly dataSource = new MatTableDataSource<Access>(); protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly displayedColumns = computed(() => { protected readonly displayedColumns = computed(() => {
const columns = [ const columns = ['alias', 'grantee', 'type', 'lastUsedAt', 'expiresAt'];
'alias',
'grantee',
'type',
'lastUsedAt',
'expiresAt',
'details'
];
if (this.showActions()) { if (this.showActions()) {
columns.push('actions'); columns.push('actions');
@ -109,8 +100,6 @@ export class GfAccessTableComponent {
return !this.accesses(); return !this.accesses();
}); });
protected readonly mcpEndpoint = MCP_ENDPOINT;
private readonly clipboard = inject(Clipboard); private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
@ -121,7 +110,6 @@ export class GfAccessTableComponent {
copyOutline, copyOutline,
createOutline, createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline,
removeCircleOutline, removeCircleOutline,
trashOutline trashOutline
}); });
@ -142,18 +130,6 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
} }
protected onCopyIdToClipboard(aId: string) {
this.clipboard.copy(aId);
this.snackBar.open(
'✅ ' + $localize`Identifier has been copied to the clipboard`,
undefined,
{
duration: ms('3 seconds')
}
);
}
protected onCopyUrlToClipboard(aId: string) { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -2,6 +2,7 @@ import { AdminMarketDataService } from '@ghostfolio/client/components/admin-mark
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
ASSET_CLASS_MAPPING, ASSET_CLASS_MAPPING,
COMMENT_MAXIMUM_LENGTH,
PROPERTY_IS_DATA_GATHERING_ENABLED PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
@ -229,6 +230,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true; protected canEditAssetProfile = true;
protected readonly COMMENT_MAXIMUM_LENGTH = COMMENT_MAXIMUM_LENGTH;
protected countries: { protected countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };

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

@ -467,6 +467,7 @@
cdkTextareaAutosize cdkTextareaAutosize
formControlName="comment" formControlName="comment"
matInput matInput
[maxlength]="COMMENT_MAXIMUM_LENGTH"
(keyup.enter)="$event.stopPropagation()" (keyup.enter)="$event.stopPropagation()"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>

37
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,5 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE, MCP_ENDPOINT } from '@ghostfolio/common/config';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { canApplyFiltersToAccess } from '@ghostfolio/common/helper'; import { canApplyFiltersToAccess } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
@ -27,6 +27,7 @@ import {
} from '@ghostfolio/ui/portfolio-filter-form'; } from '@ghostfolio/ui/portfolio-filter-form';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { JsonPipe } from '@angular/common';
import type { HttpErrorResponse } from '@angular/common/http'; import type { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -69,6 +70,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
FormsModule, FormsModule,
GfAccessLevelIconComponent, GfAccessLevelIconComponent,
GfPortfolioFilterFormComponent, GfPortfolioFilterFormComponent,
JsonPipe,
MatButtonModule, MatButtonModule,
MatDatepickerModule, MatDatepickerModule,
MatDialogModule, MatDialogModule,
@ -88,6 +90,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public tags: Filter[] = []; public tags: Filter[] = [];
protected accessForm: FormGroup; protected accessForm: FormGroup;
protected readonly baseUrl = window.location.origin;
protected minExpiresAt: Date; protected minExpiresAt: Date;
protected readonly mode: 'create' | 'update'; protected readonly mode: 'create' | 'update';
protected readonly today = startOfDay(new Date()); protected readonly today = startOfDay(new Date());
@ -222,6 +225,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.loadHoldings(); this.loadHoldings();
} }
protected get accessId() {
return this.data.access?.id;
}
protected get accessLevel(): AccessLevel { protected get accessLevel(): AccessLevel {
return this.accessForm?.get('accessLevel')?.value as AccessLevel; return this.accessForm?.get('accessLevel')?.value as AccessLevel;
} }
@ -234,10 +241,32 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return this.accessType === 'PUBLIC'; return this.accessType === 'PUBLIC';
} }
protected get mcpConfiguration() {
return {
headers: {
Authorization: `Bearer ${this.accessId}`
},
type: 'http',
url: `${this.baseUrl}${MCP_ENDPOINT}`
};
}
protected get showExpiresAtErrorMessage() { protected get showExpiresAtErrorMessage() {
return this.accessForm?.get('expiresAt')?.invalid === true; return this.accessForm?.get('expiresAt')?.invalid === true;
} }
protected get showMcpDetails() {
return this.canGrantMcpAccess && this.isMcpAccess && this.mode === 'update';
}
protected get showPublicDetails() {
return (
this.hasExperimentalFeatures &&
this.isPublicAccess &&
this.mode === 'update'
);
}
protected onCancel() { protected onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -250,6 +279,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
} }
private get isMcpAccess() {
return this.accessType === 'MCP';
}
private async createAccess() { private async createAccess() {
const filters = this.getFilters(); const filters = this.getFilters();
@ -343,7 +376,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
private async updateAccess() { private async updateAccess() {
const accessId = this.data.access?.id; const accessId = this.accessId;
if (!accessId) { if (!accessId) {
return; return;

12
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -133,6 +133,18 @@
[tags]="tags" [tags]="tags"
/> />
} }
@if (showMcpDetails || showPublicDetails) {
<h2 class="h6" i18n>Details</h2>
@if (showMcpDetails) {
<pre class="m-0"><code>{{ mcpConfiguration | json }}</code></pre>
} @else if (showPublicDetails) {
<div>
<code class="text-break"
>GET {{ baseUrl }}/api/v1/public/{{ accessId }}/portfolio</code
>
</div>
}
}
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button mat-button type="button" (click)="onCancel()"> <button mat-button type="button" (click)="onCancel()">

84
apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts

@ -5,11 +5,17 @@ import {
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import {
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import { AccountResponse, User } from '@ghostfolio/common/interfaces'; import { AccountResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Scope, hasScope, scopes } from '@ghostfolio/common/scopes'; import { Scope, hasScope, scopes } from '@ghostfolio/common/scopes';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -36,6 +42,8 @@ import {
import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component'; import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component';
import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces'; import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces';
import { TransferBalanceDialogParams } from '../transfer-balance/interfaces/interfaces';
import { GfTransferBalanceDialogComponent } from '../transfer-balance/transfer-balance-dialog.component';
import { AccountDialogMode } from './types/account-dialog-mode.type'; import { AccountDialogMode } from './types/account-dialog-mode.type';
@Component({ @Component({
@ -45,7 +53,9 @@ import { AccountDialogMode } from './types/account-dialog-mode.type';
}) })
export class GfAccountDialogHostComponent implements OnDestroy, OnInit { export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef< private dialogRef: MatDialogRef<
GfAccountDetailDialogComponent | GfCreateOrUpdateAccountDialogComponent | GfAccountDetailDialogComponent
| GfCreateOrUpdateAccountDialogComponent
| GfTransferBalanceDialogComponent
>; >;
private readonly deviceType = computed(() => { private readonly deviceType = computed(() => {
@ -61,6 +71,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
private readonly impersonationStorageService = inject( private readonly impersonationStorageService = inject(
ImpersonationStorageService ImpersonationStorageService
); );
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@ -108,6 +119,24 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
return; return;
} }
if (mode === 'transferCashBalance') {
const accounts = user?.accounts ?? [];
if (
accounts.length < 2 ||
!hasPermission(user?.permissions, permissions.updateAccount) ||
this.isWriteRestricted(user, scopes.accountUpdate)
) {
this.navigateBack();
return;
}
this.openTransferBalanceDialog({ accounts });
return;
}
if (mode === 'update') { if (mode === 'update') {
if ( if (
!account || !account ||
@ -298,4 +327,55 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
}); });
}); });
} }
private openTransferBalanceDialog({
accounts
}: {
accounts: AccountWithPlatform[];
}) {
const dialogRef = this.dialog.open<
GfTransferBalanceDialogComponent,
TransferBalanceDialogParams,
TransferBalanceDto | null
>(GfTransferBalanceDialogComponent, {
data: {
accounts
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (!result) {
this.navigateBack();
return;
}
this.dataService
.transferAccountBalance(result)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.notificationService.alert({
title: $localize`Oops, cash balance transfer has failed.`
});
this.navigateBack();
},
next: () => {
// Deliberately not bound to the destroy reference: navigating
// back destroys this component and the refreshed user is what
// makes the accounts page reload its data
this.userService.get(true).subscribe();
this.navigateBack();
}
});
});
}
} }

3
apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts

@ -1 +1,2 @@
export type AccountDialogMode = 'create' | 'detail' | 'update'; export type AccountDialogMode =
'create' | 'detail' | 'transferCashBalance' | 'update';

86
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,7 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { AccountsResponse, User } from '@ghostfolio/common/interfaces'; import { AccountsResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
@ -9,27 +8,19 @@ import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { Router, RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { filter, switchMap, tap } from 'rxjs';
import { DeviceDetectorService } from 'ngx-device-detector';
import { filter, of, switchMap, tap } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { TransferBalanceDialogParams } from './transfer-balance/interfaces/interfaces';
import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -54,33 +45,15 @@ export class GfAccountsPageComponent implements OnInit {
private hasImpersonationId: boolean; private hasImpersonationId: boolean;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject( private readonly impersonationStorageService = inject(
ImpersonationStorageService ImpersonationStorageService
); );
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['transferBalanceDialog']) {
this.openTransferBalanceDialog();
}
});
}
public ngOnInit() { public ngOnInit() {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
@ -135,67 +108,12 @@ export class GfAccountsPageComponent implements OnInit {
}); });
} }
protected onTransferBalance() {
this.router.navigate([], {
queryParams: { transferBalanceDialog: true }
});
}
private fetchAccounts() { private fetchAccounts() {
return this.dataService.fetchAccounts({ return this.dataService.fetchAccounts({
filters: this.userService.getFilters() filters: this.userService.getFilters()
}); });
} }
private openTransferBalanceDialog() {
const dialogRef = this.dialog.open<
GfTransferBalanceDialogComponent,
TransferBalanceDialogParams
>(GfTransferBalanceDialogComponent, {
data: {
accounts: this.user?.accounts
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: any) => {
if (data) {
this.reset();
const { accountIdFrom, accountIdTo, balance }: TransferBalanceDto =
data?.account;
this.dataService
.transferAccountBalance({
accountIdFrom,
accountIdTo,
balance
})
.pipe(
catchError(() => {
this.notificationService.alert({
title: $localize`Oops, cash balance transfer has failed.`
});
return of(undefined);
}),
switchMap(() => this.fetchAccounts()),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((response) => {
this.updateAccounts(response);
});
this.changeDetectorRef.markForCheck();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private reset() { private reset() {
this.accounts = undefined; this.accounts = undefined;
this.activitiesCount = 0; this.activitiesCount = 0;

1
apps/client/src/app/pages/accounts/accounts-page.html

@ -16,7 +16,6 @@
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency" [totalValueInBaseCurrency]="totalValueInBaseCurrency"
(accountDeleted)="onDeleteAccount($event)" (accountDeleted)="onDeleteAccount($event)"
(transferBalance)="onTransferBalance()"
/> />
</div> </div>
</div> </div>

9
apps/client/src/app/pages/accounts/accounts-page.routes.ts

@ -6,7 +6,8 @@ import { Routes } from '@angular/router';
import { GfAccountDialogHostComponent } from './account-dialog-host/account-dialog-host.component'; import { GfAccountDialogHostComponent } from './account-dialog-host/account-dialog-host.component';
import { GfAccountsPageComponent } from './accounts-page.component'; import { GfAccountsPageComponent } from './accounts-page.component';
const { create, detail, update } = internalRoutes.accounts.subRoutes; const { create, detail, transferCashBalance, update } =
internalRoutes.accounts.subRoutes;
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -18,6 +19,12 @@ export const routes: Routes = [
path: create.path, path: create.path,
title: create.title title: create.title
}, },
{
component: GfAccountDialogHostComponent,
data: { mode: 'transferCashBalance' },
path: transferCashBalance.path,
title: transferCashBalance.title
},
{ {
children: [ children: [
{ {

7
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -1,6 +1,9 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config'; import {
COMMENT_MAXIMUM_LENGTH,
TAG_ID_DRAFT
} from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper'; import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -62,6 +65,8 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-account-dialog.html' templateUrl: 'create-or-update-account-dialog.html'
}) })
export class GfCreateOrUpdateAccountDialogComponent { export class GfCreateOrUpdateAccountDialogComponent {
protected readonly COMMENT_MAXIMUM_LENGTH = COMMENT_MAXIMUM_LENGTH;
protected accountForm: FormGroup; protected accountForm: FormGroup;
protected currencies: string[] = []; protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined; protected filteredPlatforms: Observable<Platform[]> | undefined;

1
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -91,6 +91,7 @@
cdkTextareaAutosize cdkTextareaAutosize
formControlName="comment" formControlName="comment"
matInput matInput
[maxlength]="COMMENT_MAXIMUM_LENGTH"
(keyup.enter)="$event.stopPropagation()" (keyup.enter)="$event.stopPropagation()"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>

4
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -99,13 +99,13 @@ export class GfTransferBalanceDialogComponent {
} }
protected onSubmit() { protected onSubmit() {
const account: TransferBalanceDto = { const transferBalance: TransferBalanceDto = {
accountIdFrom: this.transferBalanceForm.controls.fromAccount.value ?? '', accountIdFrom: this.transferBalanceForm.controls.fromAccount.value ?? '',
accountIdTo: this.transferBalanceForm.controls.toAccount.value ?? '', accountIdTo: this.transferBalanceForm.controls.toAccount.value ?? '',
balance: Number(this.transferBalanceForm.controls.balance.value) balance: Number(this.transferBalanceForm.controls.balance.value)
}; };
this.dialogRef.close({ account }); this.dialogRef.close(transferBalance);
} }
private getAccountById(aId: string | null) { private getAccountById(aId: string | null) {

7
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -1,6 +1,10 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config'; import {
ASSET_CLASS_MAPPING,
COMMENT_MAXIMUM_LENGTH,
DEFAULT_LOCALE
} from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { import {
getDateFormatString, getDateFormatString,
@ -79,6 +83,7 @@ import { ActivityType } from './types/activity-type.type';
templateUrl: 'create-or-update-activity-dialog.html' templateUrl: 'create-or-update-activity-dialog.html'
}) })
export class GfCreateOrUpdateActivityDialogComponent { export class GfCreateOrUpdateActivityDialogComponent {
protected readonly COMMENT_MAXIMUM_LENGTH = COMMENT_MAXIMUM_LENGTH;
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE; protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected activityForm: FormGroup; protected activityForm: FormGroup;

1
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -255,6 +255,7 @@
cdkTextareaAutosize cdkTextareaAutosize
formControlName="comment" formControlName="comment"
matInput matInput
[maxlength]="COMMENT_MAXIMUM_LENGTH"
(keyup.enter)="$event.stopPropagation()" (keyup.enter)="$event.stopPropagation()"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>

468
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

518
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

466
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

468
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

5
libs/common/src/lib/config.ts

@ -60,6 +60,8 @@ export const BULL_BOARD_ROUTE = '/admin/queues';
export const CACHE_TTL_NO_CACHE = 1; export const CACHE_TTL_NO_CACHE = 1;
export const CACHE_TTL_INFINITE = 0; export const CACHE_TTL_INFINITE = 0;
export const COMMENT_MAXIMUM_LENGTH = 2000;
export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE'; export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE';
export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1; export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1;
export const DATA_GATHERING_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER; export const DATA_GATHERING_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER;
@ -342,6 +344,7 @@ export const REPLACE_NAME_PARTS = [
'Xtrackers (IE) Plc -' 'Xtrackers (IE) Plc -'
]; ];
export const SEARCH_QUERY_MAXIMUM_LENGTH = 255;
export const SEARCH_QUERY_MINIMUM_LENGTH = 2; export const SEARCH_QUERY_MINIMUM_LENGTH = 2;
export const SECTORS = [ export const SECTORS = [
@ -378,6 +381,8 @@ export const SUPPORTED_LANGUAGE_CODES = [
'zh' 'zh'
] as const; ] as const;
export const SYMBOL_MAXIMUM_LENGTH = 255;
export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874';
export const TAG_ID_DRAFT = '0c077abd-eca2-4cbb-818c-6cefbf2d169a'; export const TAG_ID_DRAFT = '0c077abd-eca2-4cbb-818c-6cefbf2d169a';
export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180'; export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180';

3
libs/common/src/lib/dtos/create-account.dto.ts

@ -1,3 +1,4 @@
import { COMMENT_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
@ -7,6 +8,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
ValidateIf ValidateIf
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -22,6 +24,7 @@ export class CreateAccountDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(COMMENT_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) => @Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value isString(value) ? value.trim() : value
) )

7
libs/common/src/lib/dtos/create-asset-profile.dto.ts

@ -1,3 +1,7 @@
import {
COMMENT_MAXIMUM_LENGTH,
SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
@ -9,6 +13,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
MaxLength,
ValidateNested ValidateNested
} from 'class-validator'; } from 'class-validator';
@ -27,6 +32,7 @@ export class CreateAssetProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(COMMENT_MAXIMUM_LENGTH)
comment?: string; comment?: string;
@IsArray() @IsArray()
@ -82,6 +88,7 @@ export class CreateAssetProfileDto {
sectors?: Prisma.InputJsonArray; sectors?: Prisma.InputJsonArray;
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol: string; symbol: string;
@IsOptional() @IsOptional()

7
libs/common/src/lib/dtos/create-order.dto.ts

@ -1,3 +1,7 @@
import {
COMMENT_MAXIMUM_LENGTH,
SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
@ -12,6 +16,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
Min, Min,
Validate Validate
} from 'class-validator'; } from 'class-validator';
@ -32,6 +37,7 @@ export class CreateOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(COMMENT_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) => @Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value isString(value) ? value.trim() : value
) )
@ -61,6 +67,7 @@ export class CreateOrderDto {
quantity: number; quantity: number;
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol: string; symbol: string;
@ArrayUnique() @ArrayUnique()

5
libs/common/src/lib/dtos/create-watchlist-item.dto.ts

@ -1,10 +1,13 @@
import { SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { IsEnum, IsString } from 'class-validator'; import { IsEnum, IsString, MaxLength } from 'class-validator';
export class CreateWatchlistItemDto { export class CreateWatchlistItemDto {
@IsEnum(DataSource) @IsEnum(DataSource)
dataSource: DataSource; dataSource: DataSource;
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol: string; symbol: string;
} }

3
libs/common/src/lib/dtos/update-account.dto.ts

@ -1,3 +1,4 @@
import { COMMENT_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
@ -7,6 +8,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
ValidateIf ValidateIf
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -22,6 +24,7 @@ export class UpdateAccountDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(COMMENT_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) => @Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value isString(value) ? value.trim() : value
) )

7
libs/common/src/lib/dtos/update-asset-profile.dto.ts

@ -1,3 +1,7 @@
import {
COMMENT_MAXIMUM_LENGTH,
SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { import {
@ -16,6 +20,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
MaxLength,
ValidateNested ValidateNested
} from 'class-validator'; } from 'class-validator';
@ -35,6 +40,7 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(COMMENT_MAXIMUM_LENGTH)
comment?: string | null; comment?: string | null;
@IsArray() @IsArray()
@ -83,6 +89,7 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol?: string; symbol?: string;
@IsObject() @IsObject()

7
libs/common/src/lib/dtos/update-order.dto.ts

@ -1,3 +1,7 @@
import {
COMMENT_MAXIMUM_LENGTH,
SYMBOL_MAXIMUM_LENGTH
} from '@ghostfolio/common/config';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
@ -11,6 +15,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
Min, Min,
Validate Validate
} from 'class-validator'; } from 'class-validator';
@ -31,6 +36,7 @@ export class UpdateOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(COMMENT_MAXIMUM_LENGTH)
@Transform(({ value }: TransformFnParams) => @Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value isString(value) ? value.trim() : value
) )
@ -62,6 +68,7 @@ export class UpdateOrderDto {
quantity: number; quantity: number;
@IsString() @IsString()
@MaxLength(SYMBOL_MAXIMUM_LENGTH)
symbol: string; symbol: string;
@ArrayUnique() @ArrayUnique()

20
libs/common/src/lib/personal-finance-tools.ts

@ -1590,6 +1590,26 @@ export const personalFinanceTools: Product[] = [
slogan: 'The Intelligent Portfolio Tracker', slogan: 'The Intelligent Portfolio Tracker',
url: 'https://www.navexa.com' url: 'https://www.navexa.com'
}, },
{
categories: [
'BUDGETING',
'CRYPTOCURRENCY',
'ETF_TRACKING',
'FINANCIAL_PLANNING',
'NET_WORTH_TRACKING',
'STOCK_TRACKING'
],
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'neontra',
languages: ['English'],
name: 'Neontra',
origin: 'CA',
platforms: ['ANDROID', 'IOS', 'WEB'],
pricingPerYear: '$75',
slogan: 'Everything you own. Everything you owe. Everything in one place.',
url: 'https://neontra.com'
},
{ {
categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'], categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'],
hasFreePlan: true, hasFreePlan: true,

5
libs/common/src/lib/routes/routes.ts

@ -93,6 +93,11 @@ export const internalRoutes = {
}, },
title: $localize`Account` title: $localize`Account`
}, },
transferCashBalance: {
path: 'transfer-cash-balance',
routerLink: ['/accounts', 'transfer-cash-balance'],
title: $localize`Transfer Cash Balance`
},
update: { update: {
path: 'update', path: 'update',
routerLink: (aAccountId: string) => { routerLink: (aAccountId: string) => {

2
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -4,7 +4,7 @@
class="align-items-center d-flex" class="align-items-center d-flex"
mat-stroked-button mat-stroked-button
[disabled]="dataSource?.data.length < 2" [disabled]="dataSource?.data.length < 2"
(click)="onTransferBalance()" [routerLink]="transferCashBalanceRouterLink"
> >
<ion-icon class="mr-2" name="arrow-redo-outline" /> <ion-icon class="mr-2" name="arrow-redo-outline" />
<span><ng-container i18n>Transfer Cash Balance</ng-container>...</span> <span><ng-container i18n>Transfer Cash Balance</ng-container>...</span>

8
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -75,7 +75,6 @@ export class GfAccountsTableComponent {
public readonly totalValueInBaseCurrency = input<number>(); public readonly totalValueInBaseCurrency = input<number>();
public readonly accountDeleted = output<string>(); public readonly accountDeleted = output<string>();
public readonly transferBalance = output<void>();
public readonly sort = viewChild.required(MatSort); public readonly sort = viewChild.required(MatSort);
@ -141,6 +140,9 @@ export class GfAccountsTableComponent {
protected readonly isLoading = computed(() => !this.accounts()); protected readonly isLoading = computed(() => !this.accounts());
protected readonly transferCashBalanceRouterLink =
internalRoutes.accounts.subRoutes.transferCashBalance.routerLink;
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly router = inject(Router); private readonly router = inject(Router);
@ -197,8 +199,4 @@ export class GfAccountsTableComponent {
title: aComment title: aComment
}); });
} }
protected onTransferBalance() {
this.transferBalance.emit();
}
} }

2
libs/ui/src/lib/assistant/assistant.component.ts

@ -1,3 +1,4 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface'; import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
@ -130,6 +131,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
); );
public searchFormControl = new FormControl(''); public searchFormControl = new FormControl('');
public readonly SEARCH_QUERY_MAXIMUM_LENGTH = SEARCH_QUERY_MAXIMUM_LENGTH;
public searchResults: SearchResults = { public searchResults: SearchResults = {
accounts: [], accounts: [],

1
libs/ui/src/lib/assistant/assistant.html

@ -13,6 +13,7 @@
name="search" name="search"
type="text" type="text"
[formControl]="searchFormControl" [formControl]="searchFormControl"
[maxlength]="SEARCH_QUERY_MAXIMUM_LENGTH"
[placeholder]="placeholder" [placeholder]="placeholder"
/> />
@if (deviceType !== 'mobile' && !searchFormControl.value) { @if (deviceType !== 'mobile' && !searchFormControl.value) {

1
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html

@ -4,6 +4,7 @@
matInput matInput
[formControl]="control" [formControl]="control"
[matAutocomplete]="symbolAutocomplete" [matAutocomplete]="symbolAutocomplete"
[maxlength]="SEARCH_QUERY_MAXIMUM_LENGTH"
/> />
<mat-autocomplete <mat-autocomplete

2
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -1,3 +1,4 @@
import { SEARCH_QUERY_MAXIMUM_LENGTH } from '@ghostfolio/common/config';
import { LookupItem } from '@ghostfolio/common/interfaces'; import { LookupItem } from '@ghostfolio/common/interfaces';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -86,6 +87,7 @@ export class GfSymbolAutocompleteComponent
public readonly control = new FormControl(); public readonly control = new FormControl();
public lookupItems: (LookupItem & { assetSubClassString: string })[] = []; public lookupItems: (LookupItem & { assetSubClassString: string })[] = [];
public readonly SEARCH_QUERY_MAXIMUM_LENGTH = SEARCH_QUERY_MAXIMUM_LENGTH;
protected readonly symbolAutocomplete = protected readonly symbolAutocomplete =
viewChild.required<MatAutocomplete>('symbolAutocomplete'); viewChild.required<MatAutocomplete>('symbolAutocomplete');

120
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.65.0", "version": "3.66.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.65.0", "version": "3.66.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -47,9 +47,9 @@
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.9.1", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.9.1", "@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.0", "@rekog/mcp-nest": "2.0.2",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -134,7 +134,7 @@
"@nx/storybook": "23.1.1", "@nx/storybook": "23.1.1",
"@nx/web": "23.1.1", "@nx/web": "23.1.1",
"@nx/workspace": "23.1.1", "@nx/workspace": "23.1.1",
"@prisma/config": "7.9.1", "@prisma/config": "7.10.0",
"@schematics/angular": "22.1.4", "@schematics/angular": "22.1.4",
"@storybook/addon-docs": "10.5.7", "@storybook/addon-docs": "10.5.7",
"@storybook/addon-themes": "10.5.7", "@storybook/addon-themes": "10.5.7",
@ -165,7 +165,7 @@
"nx": "23.1.1", "nx": "23.1.1",
"prettier": "3.9.6", "prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.9.1", "prisma": "7.10.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "9.0.0", "replace-in-file": "9.0.0",
@ -13186,24 +13186,24 @@
} }
}, },
"node_modules/@prisma/adapter-pg": { "node_modules/@prisma/adapter-pg": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
"integrity": "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==", "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/driver-adapter-utils": "7.9.1", "@prisma/driver-adapter-utils": "7.10.0",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"postgres-array": "3.0.4" "postgres-array": "3.0.4"
} }
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz",
"integrity": "sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==", "integrity": "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/client-runtime-utils": "7.9.1" "@prisma/client-runtime-utils": "7.10.0"
}, },
"engines": { "engines": {
"node": "^20.19 || ^22.12 || >=24.0" "node": "^20.19 || ^22.12 || >=24.0"
@ -13222,15 +13222,15 @@
} }
}, },
"node_modules/@prisma/client-runtime-utils": { "node_modules/@prisma/client-runtime-utils": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.0.tgz",
"integrity": "sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==", "integrity": "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.0.tgz",
"integrity": "sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==", "integrity": "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -13241,9 +13241,9 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz",
"integrity": "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==", "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/dev": { "node_modules/@prisma/dev": {
@ -13271,65 +13271,65 @@
} }
}, },
"node_modules/@prisma/driver-adapter-utils": { "node_modules/@prisma/driver-adapter-utils": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz",
"integrity": "sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==", "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.9.1" "@prisma/debug": "7.10.0"
} }
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz",
"integrity": "sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==", "integrity": "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.9.1", "@prisma/debug": "7.10.0",
"@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
"@prisma/fetch-engine": "7.9.1", "@prisma/fetch-engine": "7.10.0",
"@prisma/get-platform": "7.9.1" "@prisma/get-platform": "7.10.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", "version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3.tgz",
"integrity": "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==", "integrity": "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
"integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==", "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.9.1" "@prisma/debug": "7.10.0"
} }
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.0.tgz",
"integrity": "sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==", "integrity": "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.9.1", "@prisma/debug": "7.10.0",
"@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
"@prisma/get-platform": "7.9.1" "@prisma/get-platform": "7.10.0"
} }
}, },
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
"integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==", "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "7.9.1" "@prisma/debug": "7.10.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
@ -13587,9 +13587,9 @@
} }
}, },
"node_modules/@rekog/mcp-nest": { "node_modules/@rekog/mcp-nest": {
"version": "2.0.0", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.2.tgz",
"integrity": "sha512-lT5V26V4fvPay50zMiw2KrEqlDSuzdAeyoQFd0+vfAwb40G6H8p8psTzgq5atTgNHktRPI7u4x8lrm8X844Asg==", "integrity": "sha512-kuye6wmRk5Gw4KZSCSXd83cWznRw4eFbvNUXsd8+1dYcS4uWKVVbRvxQIKNCyxc2UcljpsmLpnksr1NCRn3YAQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"multer": "^2.2.0", "multer": "^2.2.0",
@ -30547,16 +30547,16 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "7.9.1", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz",
"integrity": "sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==", "integrity": "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/config": "7.9.1", "@prisma/config": "7.10.0",
"@prisma/dev": "0.24.17", "@prisma/dev": "0.24.17",
"@prisma/engines": "7.9.1", "@prisma/engines": "7.10.0",
"@prisma/studio-core": "0.33.0", "@prisma/studio-core": "0.33.0",
"mysql2": "3.15.3", "mysql2": "3.15.3",
"postgres": "3.4.7" "postgres": "3.4.7"

12
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.65.0", "version": "3.66.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",
@ -91,9 +91,9 @@
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0", "@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.9.1", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.9.1", "@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.0", "@rekog/mcp-nest": "2.0.2",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -178,7 +178,7 @@
"@nx/storybook": "23.1.1", "@nx/storybook": "23.1.1",
"@nx/web": "23.1.1", "@nx/web": "23.1.1",
"@nx/workspace": "23.1.1", "@nx/workspace": "23.1.1",
"@prisma/config": "7.9.1", "@prisma/config": "7.10.0",
"@schematics/angular": "22.1.4", "@schematics/angular": "22.1.4",
"@storybook/addon-docs": "10.5.7", "@storybook/addon-docs": "10.5.7",
"@storybook/addon-themes": "10.5.7", "@storybook/addon-themes": "10.5.7",
@ -209,7 +209,7 @@
"nx": "23.1.1", "nx": "23.1.1",
"prettier": "3.9.6", "prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.9.1", "prisma": "7.10.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "9.0.0", "replace-in-file": "9.0.0",

Loading…
Cancel
Save