diff --git a/CHANGELOG.md b/CHANGELOG.md index 4499302bf..5f19caded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Restricted the support for filtering to the public access to share the portfolio (experimental) - Improved the language localization for German (`de`) ### Fixed diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index eebe28a53..f68b25d61 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -4,7 +4,10 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; -import { isValidGranteeOfAccess } from '@ghostfolio/common/helper'; +import { + canApplyFiltersToAccess, + isValidGranteeOfAccess +} from '@ghostfolio/common/helper'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import { getScopesOfAccess } from '@ghostfolio/common/scopes'; @@ -53,6 +56,8 @@ export class AccessController { const { alias, expiresAt, granteeUser, id, lastUsedAt, settings, type } = accessItem; + const { filters } = (settings ?? {}) as AccessSettings; + return { alias, expiresAt, @@ -61,7 +66,7 @@ export class AccessController { type, grantee: granteeUser?.id, scopes: getScopesOfAccess(accessItem), - settings: settings as AccessSettings + settings: canApplyFiltersToAccess({ type }) ? { filters } : {} }; }); } @@ -104,6 +109,13 @@ export class AccessController { ); } + if (data.filters?.length && !canApplyFiltersToAccess({ type })) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + try { return await this.accessService.createAccess({ type, @@ -192,6 +204,16 @@ export class AccessController { ); } + if ( + data.filters?.length && + !canApplyFiltersToAccess({ type: originalAccess.type }) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + try { return await this.accessService.updateAccess({ data: { diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 9e6f29e52..114a9c58f 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -88,12 +88,7 @@ export class GhostfolioMcpController { }) public async getActivities( @Impersonation() - { - filters, - scopes: scopesOfAccess, - userId, - userSettings - }: ImpersonationContext, + { scopes: scopesOfAccess, userId, userSettings }: ImpersonationContext, @Payload() { activityTypes, @@ -113,65 +108,18 @@ export class GhostfolioMcpController { })); } - const filtersOfAccess = filters ?? []; - - const filtersOfTool = this.apiService.buildFiltersFromQueryParams({ + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAssetClasses: assetClasses?.join(','), filterByDataSource: holding?.dataSource, filterBySymbol: holding?.symbol }); - // A tool must never widen the access, hence a filter of the tool which - // the access does not permit gives no activity - const filtersOfToolOutsideAccess = filtersOfTool.filter(({ id, type }) => { - const filtersOfAccessOfType = filtersOfAccess.filter((filter) => { - return filter.type === type; - }); - - return ( - filtersOfAccessOfType.length > 0 && - !filtersOfAccessOfType.some((filter) => { - return filter.id === id; - }) - ); - }); - - if (filtersOfToolOutsideAccess.length > 0) { - const valuesOutsideAccess = filtersOfToolOutsideAccess - .map(({ id }) => { - return id; - }) - .join(', '); - - return { - content: [ - { - text: `No activities found. The access does not permit these values of the parameters: ${valuesOutsideAccess}.`, - type: 'text' as const - } - ] - }; - } - - const typesOfFiltersOfTool = new Set( - filtersOfTool.map(({ type }) => { - return type; - }) - ); - - // The filters of a type are combined with a logical or, hence a filter of - // the tool replaces the filters of the access of the same type instead of - // joining them - const filtersOfAccessOutsideTool = filtersOfAccess.filter(({ type }) => { - return !typesOfFiltersOfTool.has(type); - }); - const table = await this.aiService.getActivitiesTable({ endDate, + filters, skip, startDate, userId, - filters: [...filtersOfAccessOutsideTool, ...filtersOfTool], take: take ?? MCP_MAX_ACTIVITIES, types: activityTypes, userCurrency: userSettings.baseCurrency, @@ -194,10 +142,9 @@ export class GhostfolioMcpController { name: 'get-portfolio' }) public async getPortfolio( - @Impersonation() { filters, userId, userSettings }: ImpersonationContext + @Impersonation() { userId, userSettings }: ImpersonationContext ) { const prompt = await this.aiService.getPrompt({ - filters, userId, languageCode: DEFAULT_LANGUAGE_CODE, mode: 'portfolio', diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index 2a7915aa5..1cce830b5 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -3,7 +3,7 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; -import { AccessSettings, UserSettings } from '@ghostfolio/common/interfaces'; +import { UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { getScopesOfAccess, @@ -65,11 +65,9 @@ export class ImpersonationService { where: { id: impersonatedUserId } }); - const { filters } = (access?.settings ?? {}) as AccessSettings; const settings = impersonatedUser?.settings?.settings as UserSettings; return { - filters, accessId: impersonationId, authenticatedUserSubscription: user?.subscription, isActive: true, diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index f28d13544..82924b81f 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -1,6 +1,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; +import { canApplyFiltersToAccess } from '@ghostfolio/common/helper'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { @@ -52,6 +53,7 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { AccessType } from '@prisma/client'; import { addYears, endOfDay, isBefore, isValid, startOfDay } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, catchError } from 'rxjs'; @@ -112,7 +114,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } public get canApplyFilters() { - return this.isPublicAccess && this.hasExperimentalFeatures; + return ( + canApplyFiltersToAccess({ type: this.accessType }) && + this.hasExperimentalFeatures + ); } public get canGrantMcpAccess() { @@ -177,7 +182,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.accessForm .get('type') ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((accessType) => { + .subscribe((accessType: AccessType) => { const granteeUserIdControl = this.accessForm.get('granteeUserId'); if (accessType === 'PRIVATE') { @@ -191,8 +196,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.accessForm.get('accessLevel')?.setValue('READ_RESTRICTED'); } - if (accessType !== 'PUBLIC') { - // Only a public access can be limited to a part of the portfolio + if (!canApplyFiltersToAccess({ type: accessType })) { this.accessForm.get('filters')?.setValue(null); } @@ -208,8 +212,12 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { return this.accessForm?.get('accessLevel')?.value as AccessLevel; } + protected get accessType(): AccessType { + return this.accessForm?.get('type')?.value as AccessType; + } + protected get isPublicAccess() { - return this.accessForm?.get('type')?.value === 'PUBLIC'; + return this.accessType === 'PUBLIC'; } protected get showExpiresAtErrorMessage() { diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 9fd4e8f38..0a17d2058 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -3,6 +3,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { + canApplyFiltersToAccess, extractNumberFromString, getCountryCodeFromCurrency, getNumberFormatGroup, @@ -19,6 +20,20 @@ import { import { UserSettings } from '@ghostfolio/common/interfaces'; describe('Helper', () => { + describe('Can apply filters to access', () => { + it('An access of the model context protocol', () => { + expect(canApplyFiltersToAccess({ type: 'MCP' })).toEqual(false); + }); + + it('A private access', () => { + expect(canApplyFiltersToAccess({ type: 'PRIVATE' })).toEqual(false); + }); + + it('A public access', () => { + expect(canApplyFiltersToAccess({ type: 'PUBLIC' })).toEqual(true); + }); + }); + describe('Extract number from string', () => { it('Get decimal number', () => { expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index a51a74808..6accde31c 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -174,6 +174,10 @@ export function calculateMovingAverage({ .toNumber(); } +export function canApplyFiltersToAccess({ type }: { type: AccessType }) { + return type === 'PUBLIC'; +} + export function canDeleteAssetProfile({ activitiesCount, isBenchmark, diff --git a/libs/common/src/lib/types/impersonation-context.type.ts b/libs/common/src/lib/types/impersonation-context.type.ts index b125191db..29b7970b1 100644 --- a/libs/common/src/lib/types/impersonation-context.type.ts +++ b/libs/common/src/lib/types/impersonation-context.type.ts @@ -1,4 +1,4 @@ -import { Filter, UserSettings } from '@ghostfolio/common/interfaces'; +import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; /** @@ -10,7 +10,6 @@ import { UserWithSettings } from '@ghostfolio/common/types'; export interface ImpersonationContext { accessId?: string; authenticatedUserSubscription?: UserWithSettings['subscription']; - filters?: Filter[]; isActive: boolean; scopes: string[]; userId: string;