Browse Source

Add MCP tool to import activities

pull/7766/head
Thomas Kaul 2 months ago
parent
commit
ca151b84be
  1. 227
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  2. 63
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  3. 7
      apps/api/src/app/import/errors/import-validation.error.ts
  4. 28
      apps/api/src/app/import/import.controller.ts
  5. 52
      apps/api/src/app/import/import.service.ts
  6. 12
      apps/api/src/helper/data-source.helper.ts
  7. 27
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  8. 54
      libs/common/src/lib/helper.spec.ts
  9. 19
      libs/common/src/lib/helper.ts
  10. 8
      libs/common/src/lib/validator-constraints/is-after-1970.ts
  11. 13
      libs/common/src/lib/validators/is-currency-code.ts

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

@ -0,0 +1,227 @@
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { Activity } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes';
import type {
ImpersonationContext,
UserWithSettings
} from '@ghostfolio/common/types';
import { HttpException } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { DataSource, Type as ActivityType } from '@prisma/client';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import {
GhostfolioMcpController,
IMPORT_ACTIVITIES_PARAMETERS
} from './mcp.controller';
// The controller reads the columns of the tables from the AiService, which
// 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() };
});
function createActivity(overrides: Record<string, unknown> = {}) {
return {
currency: 'USD',
date: '2024-01-01',
fee: 0,
quantity: 1,
symbol: 'AAPL',
type: ActivityType.BUY,
unitPrice: 100,
...overrides
};
}
describe('GhostfolioMcpController', () => {
const impersonation = { userId: 'user-id' } as ImpersonationContext;
let configurationService: ConfigurationService;
let controller: GhostfolioMcpController;
let importService: ImportService;
let userService: UserService;
function setupUser(userPermissions: string[]) {
jest.spyOn(userService, 'user').mockResolvedValue({
permissions: userPermissions
} as UserWithSettings);
}
beforeEach(() => {
configurationService = {
get: jest.fn().mockReturnValue([])
} as unknown as ConfigurationService;
importService = {
import: jest.fn().mockResolvedValue([])
} as unknown as ImportService;
userService = { user: jest.fn() } as unknown as UserService;
controller = new GhostfolioMcpController(
undefined,
undefined,
configurationService,
importService,
userService
);
});
describe('Import activities', () => {
it('Requires the scope to create an activity', () => {
expect(
Reflect.getMetadata(
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]);
jest
.spyOn(configurationService, 'get')
.mockReturnValue([DataSource.YAHOO]);
await controller.importActivities(impersonation, {
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })]
});
expect(importService.import).toHaveBeenCalledWith(
expect.objectContaining({
activitiesDto: [
expect.objectContaining({ dataSource: DataSource.YAHOO })
]
})
);
});
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', async () => {
setupUser([permissions.createActivity]);
jest
.spyOn(importService, 'import')
.mockRejectedValue(
new Error('Unique constraint failed on the fields: (dataSource)')
);
await expect(
controller.importActivities(impersonation, {
activities: [createActivity()]
})
).rejects.toThrow(
new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR))
);
});
});
describe('Parameters of the tool to import activities', () => {
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);
});
});
});

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

@ -1,10 +1,12 @@
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 { 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 { 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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
@ -14,8 +16,10 @@ import {
MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { isCurrency } from '@ghostfolio/common/helper';
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';
@ -25,7 +29,6 @@ import { HttpException, UseFilters } from '@nestjs/common';
import { Payload, RpcException } from '@nestjs/microservices';
import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client';
import { McpController, Tool } from '@rekog/mcp-nest';
import { isValid, parseISO } from 'date-fns';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { z } from 'zod';
@ -98,20 +101,21 @@ const GET_ACTIVITIES_PARAMETERS = z.object({
.describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`)
});
const IMPORT_ACTIVITIES_PARAMETERS = z.object({
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(isCurrency)
.refine(isValidCurrencyCode)
.describe(
'The currency of the fee and of the unit price, as an ISO 4217 code'
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case'
),
dataSource: z
.enum(DataSource)
@ -119,19 +123,13 @@ const IMPORT_ACTIVITIES_PARAMETERS = z.object({
.describe('The data source of the asset profile'),
date: z
.string()
.refine((date) => {
return isValid(parseISO(date));
})
.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().describe('The symbol of the asset profile'),
tags: z
.array(z.string())
.optional()
.describe('The identifiers of the tags 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')
})
@ -266,6 +264,12 @@ export class GhostfolioMcpController {
return { content: [{ text: prompt, type: 'text' as const }] };
}
/**
* The transport gives the tool to every client, because it filters the list
* of the tools by the scopes of request.user, which a request of an access
* never has. The guard refuses the call itself, hence the description names
* the permission which the access needs.
*/
@RequiresScopeOfAccess(scopes.activityCreate)
@Tool({
annotations: {
@ -274,7 +278,7 @@ export class GhostfolioMcpController {
readOnlyHint: false,
title: 'Import activities'
},
description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the activities which are skipped, because an equal activity exists already. At most ${MCP_MAX_ACTIVITIES} activities are imported per call.`,
description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`,
name: 'import-activities',
parameters: IMPORT_ACTIVITIES_PARAMETERS
})
@ -291,34 +295,37 @@ export class GhostfolioMcpController {
);
}
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
const ghostfolioDataSources = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription?.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
const activitiesDto = activities.map((activity) => {
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
})
};
});
let importedActivities: Activity[];
try {
importedActivities = await this.importService.import({
maxActivitiesToImport,
activitiesDto,
user,
accountsWithBalancesDto: [],
activitiesDto: activities,
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: []
});
} catch (error) {
// The message of the import names the activity which is not valid and
// is written for the caller, hence it is passed on
// The message of a validation names the activity which is not valid and
// is written for the caller, hence it is passed on. Every other error
// can carry internals of the application.
throw new RpcException(
error instanceof Error
error instanceof ImportValidationError
? error.message
: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)
);

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

@ -0,0 +1,7 @@
export class ImportValidationError extends Error {
public constructor(message: string) {
super(message);
this.name = 'ImportValidationError';
}
}

28
apps/api/src/app/import/import.controller.ts

@ -2,8 +2,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -34,7 +32,6 @@ export class ImportController {
private readonly logger = new Logger(ImportController.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -59,21 +56,9 @@ export class ImportController {
);
}
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription?.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
try {
const activities = await this.importService.import({
isDryRun,
maxActivitiesToImport,
accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
@ -104,16 +89,9 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<ImportResponse> {
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription?.type === SubscriptionType.Premium
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
const maxActivitiesToImport = this.importService.getMaxActivitiesToImport({
user: this.request.user
});
const activities = await this.importService.getDividends({
dataSource,

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

@ -24,6 +24,7 @@ import {
CreateAssetProfileDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
@ -42,13 +43,14 @@ import {
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma } from '@prisma/client';
import { Account, DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { isISIN } from 'class-validator';
import { isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
import { ImportValidationError } from './errors/import-validation.error';
import { ImportDataDto } from './import-data.dto';
import { AssetProfileToCreate } from './interfaces/asset-profile-to-create.interface';
@ -186,12 +188,22 @@ export class ImportService {
}
}
public getMaxActivitiesToImport({ user }: { user: UserWithSettings }) {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription?.type === SubscriptionType.Premium
) {
return Number.MAX_SAFE_INTEGER;
}
return this.configurationService.get('MAX_ACTIVITIES_TO_IMPORT');
}
public async import({
accountsWithBalancesDto,
activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun = false,
maxActivitiesToImport,
platformsDto,
tagsDto,
user
@ -200,7 +212,6 @@ export class ImportService {
activitiesDto: ImportDataDto['activities'];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean;
maxActivitiesToImport: number;
platformsDto: ImportDataDto['platforms'];
tagsDto: ImportDataDto['tags'];
user: UserWithSettings;
@ -210,6 +221,7 @@ export class ImportService {
const ghostfolioDataSources = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
);
const maxActivitiesToImport = this.getMaxActivitiesToImport({ user });
const platformIdMapping: { [oldPlatformId: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency;
@ -224,7 +236,7 @@ export class ImportService {
dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(symbol)
) {
throw new Error(
throw new ImportValidationError(
`assetProfiles.${index}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
} else if (
@ -236,7 +248,7 @@ export class ImportService {
ghostfolioDataSources
});
throw new Error(
throw new ImportValidationError(
`assetProfiles.${index}.symbol ("${symbol}") is not valid for the data source ("${maskedDataSource}")`
);
}
@ -261,7 +273,7 @@ export class ImportService {
activity.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(activity.symbol)
) {
throw new Error(
throw new ImportValidationError(
`activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
} else if (
@ -273,7 +285,7 @@ export class ImportService {
dataSource: activity.dataSource
});
throw new Error(
throw new ImportValidationError(
`activities.${index}.symbol ("${activity.symbol}") is not valid for the data source ("${maskedDataSource}")`
);
}
@ -358,7 +370,7 @@ export class ImportService {
}
} else {
if (!canCreatePlatform) {
throw new Error(
throw new ImportValidationError(
`Insufficient permissions to create platform ("${platform.name}")`
);
}
@ -388,7 +400,7 @@ export class ImportService {
if (!existingTagOfUser) {
if (!canCreateOwnTag) {
throw new Error(
throw new ImportValidationError(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
@ -729,12 +741,22 @@ export class ImportService {
}
}
const assetProfiles = await this.dataProviderService.validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
subscription: user.subscription
});
let assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
};
try {
assetProfiles = await this.dataProviderService.validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
subscription: user.subscription
});
} catch (error) {
// The messages of the validation name the activity which is not valid
// and are written for the caller
throw new ImportValidationError(error.message);
}
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto,

12
apps/api/src/helper/data-source.helper.ts

@ -65,3 +65,15 @@ export function getMaskedGhostfolioDataSource({
? DataSource.GHOSTFOLIO
: dataSource;
}
export function getUnmaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
}: {
dataSource?: DataSource;
ghostfolioDataSources: string[];
}) {
return dataSource === DataSource.GHOSTFOLIO && ghostfolioDataSources?.[0]
? (ghostfolioDataSources[0] as DataSource)
: dataSource;
}

27
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -1,4 +1,7 @@
import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import {
decodeDataSource,
getUnmaskedGhostfolioDataSource
} from '@ghostfolio/api/helper/data-source.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
@ -27,23 +30,19 @@ export class TransformDataSourceInRequestInterceptor<
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body?.activities) {
const dataSourceGhostfolioDataProvider = this.configurationService.get(
const ghostfolioDataSources = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
)?.[0];
);
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
if (
activity.dataSource === 'GHOSTFOLIO' &&
dataSourceGhostfolioDataProvider
) {
return {
...activity,
dataSource: dataSourceGhostfolioDataProvider
};
} else {
return activity;
}
return {
...activity,
dataSource: getUnmaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
})
};
} else {
return {
...activity,

54
libs/common/src/lib/helper.spec.ts

@ -13,7 +13,9 @@ import {
isCurrency,
isCurrencySymbol,
isSplitRatio,
isValidCurrencyCode,
isValidCustomAssetProfileSymbol,
isValidDateAfter1970,
isValidGranteeOfAccess,
resolveUserSettings
} from '@ghostfolio/common/helper';
@ -371,6 +373,28 @@ describe('Helper', () => {
});
});
describe('Is valid currency code', () => {
it('Currency code in lower case', () => {
expect(isValidCurrencyCode('usd')).toEqual(false);
});
it('Currency code in upper case', () => {
expect(isValidCurrencyCode('USD')).toEqual(true);
});
it('Derived currency', () => {
expect(isValidCurrencyCode('GBp')).toEqual(true);
});
it('Empty currency code', () => {
expect(isValidCurrencyCode('')).toEqual(false);
});
it('Unknown currency code', () => {
expect(isValidCurrencyCode('XYZ')).toEqual(false);
});
});
describe('Is valid custom asset profile symbol', () => {
it('Empty symbol', () => {
expect(isValidCustomAssetProfileSymbol('')).toEqual(false);
@ -399,6 +423,36 @@ describe('Helper', () => {
});
});
describe('Is valid date after 1970', () => {
it('Date', () => {
expect(isValidDateAfter1970('2024-01-01')).toEqual(true);
});
it('Date and time', () => {
expect(isValidDateAfter1970('2024-01-01T12:00:00.000Z')).toEqual(true);
});
it('Date before 1970', () => {
expect(isValidDateAfter1970('0000-01-01')).toEqual(false);
});
it('Date of 1970', () => {
expect(isValidDateAfter1970('1970-01-01T00:00:00.000Z')).toEqual(false);
});
it('Date with an expanded year', () => {
expect(isValidDateAfter1970('+010000-01-01')).toEqual(true);
});
it('Empty date', () => {
expect(isValidDateAfter1970('')).toEqual(false);
});
it('Free-text date', () => {
expect(isValidDateAfter1970('yesterday')).toEqual(false);
});
});
describe('Is valid grantee of access', () => {
const granteeUserId = 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d';

19
libs/common/src/lib/helper.ts

@ -15,7 +15,9 @@ import {
getDate,
getMonth,
getYear,
isAfter,
isMatch,
isValid,
parse,
parseISO,
subDays
@ -668,10 +670,27 @@ export function isUserSettingOfAuthenticatedUser(aKey: string) {
);
}
export function isValidCurrencyCode(aCurrency: string) {
if (!aCurrency) {
return false;
}
return (
isDerivedCurrency(aCurrency) ||
(aCurrency === aCurrency.toUpperCase() && isISO4217CurrencyCode(aCurrency))
);
}
export function isValidCustomAssetProfileSymbol(aSymbol: string) {
return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol);
}
export function isValidDateAfter1970(aDate: string) {
const date = parseISO(aDate);
return isValid(date) && isAfter(date, new Date(0));
}
/**
* A private access is granted to a user, while a public access and an access
* of a client of the model context protocol are credentials on their own and

8
libs/common/src/lib/validator-constraints/is-after-1970.ts

@ -1,8 +1,10 @@
import { isValidDateAfter1970 } from '@ghostfolio/common/helper';
import {
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
import { format, isAfter } from 'date-fns';
import { format } from 'date-fns';
@ValidatorConstraint({ name: 'isAfter1970' })
export class IsAfter1970Constraint implements ValidatorConstraintInterface {
@ -10,7 +12,7 @@ export class IsAfter1970Constraint implements ValidatorConstraintInterface {
return `date must be after ${format(new Date(0), 'yyyy')}`;
}
public validate(aDate: Date) {
return isAfter(aDate, new Date(0));
public validate(aDate: string) {
return isValidDateAfter1970(aDate);
}
}

13
libs/common/src/lib/validators/is-currency-code.ts

@ -1,4 +1,4 @@
import { isDerivedCurrency } from '@ghostfolio/common/helper';
import { isValidCurrencyCode } from '@ghostfolio/common/helper';
import {
registerDecorator,
@ -6,7 +6,6 @@ import {
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
import { isISO4217CurrencyCode } from 'class-validator';
export function IsCurrencyCode(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
@ -27,14 +26,6 @@ export class IsExtendedCurrencyConstraint implements ValidatorConstraintInterfac
}
public validate(currency: string) {
// Return true if currency is a derived currency or a standard ISO 4217 code
return (
isDerivedCurrency(currency) ||
(this.isUpperCase(currency) && isISO4217CurrencyCode(currency))
);
}
private isUpperCase(aString: string) {
return aString === aString?.toUpperCase();
return isValidCurrencyCode(currency);
}
}

Loading…
Cancel
Save