From 05281d5980f90c84f13f619b3dc169dac64adc24 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:18:07 +0200 Subject: [PATCH] Feature/public access filtering (#7146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add filtering for public access * Update changelog --------- Co-authored-by: Germán Martín --- CHANGELOG.md | 4 + apps/api/src/app/access/access.controller.ts | 10 +- apps/api/src/app/access/access.service.ts | 7 + .../app/endpoints/public/public.controller.ts | 10 +- ...reate-or-update-access-dialog.component.ts | 79 +++++++++++ .../create-or-update-access-dialog.html | 12 ++ .../user-account-access.component.ts | 1 + libs/common/src/lib/dtos/create-access.dto.ts | 8 +- libs/common/src/lib/dtos/update-access.dto.ts | 8 +- .../interfaces/access-settings.interface.ts | 5 + .../src/lib/interfaces/access.interface.ts | 3 + libs/common/src/lib/interfaces/index.ts | 2 + .../src/lib/assistant/assistant.component.ts | 128 ++++++------------ .../ui/src/lib/portfolio-filter-form/index.ts | 1 + .../portfolio-filter-form.util.ts | 122 +++++++++++++++++ 15 files changed, 310 insertions(+), 90 deletions(-) create mode 100644 libs/common/src/lib/interfaces/access-settings.interface.ts create mode 100644 libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 051addbb2..4ba2568bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Added + +- Added support for filtering in the public access for portfolio sharing (experimental) + ## 3.17.0 - 2026-06-26 ### Added diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 28b459203..35b1d485b 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -3,7 +3,7 @@ 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 { Access } from '@ghostfolio/common/interfaces'; +import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -46,13 +46,14 @@ export class AccessController { }); return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions }) => { + ({ alias, granteeUser, id, permissions, settings }) => { if (granteeUser) { return { alias, id, permissions, grantee: granteeUser?.id, + settings: settings as AccessSettings, type: 'PRIVATE' }; } @@ -62,6 +63,7 @@ export class AccessController { id, permissions, grantee: 'Public', + settings: settings as AccessSettings, type: 'PUBLIC' }; } @@ -91,6 +93,7 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); } catch { @@ -158,7 +161,8 @@ export class AccessController { granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, - permissions: data.permissions + permissions: data.permissions, + settings: this.accessService.buildSettings(data.filters) }, where: { id } }); diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 70e46dc36..e50a6c7d0 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -1,4 +1,5 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { AccessSettings, Filter } from '@ghostfolio/common/interfaces'; import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; @@ -39,6 +40,12 @@ export class AccessService { }); } + public buildSettings(filters?: Filter[]) { + const settings: AccessSettings = filters?.length ? { filters } : {}; + + return settings as Prisma.InputJsonValue; + } + public async createAccess(data: Prisma.AccessCreateInput): Promise { return this.prismaService.access.create({ data diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 08e49704b..6e025936d 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -9,7 +9,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { getSum } from '@ghostfolio/common/helper'; -import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; +import { + AccessSettings, + PublicPortfolioResponse +} from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -66,6 +69,8 @@ export class PublicController { hasDetails = user.subscription.type === SubscriptionType.Premium; } + const { filters } = (access.settings ?? {}) as AccessSettings; + const [ { createdAt, holdings, markets }, { performance: performance1d }, @@ -73,6 +78,7 @@ export class PublicController { { performance: performanceYtd } ] = await Promise.all([ this.portfolioService.getDetails({ + filters, impersonationId: access.userId, userId: user.id, withMarkets: true @@ -80,6 +86,7 @@ export class PublicController { ...['1d', 'max', 'ytd'].map((dateRange) => { return this.portfolioService.getPerformance({ dateRange, + filters, impersonationId: undefined, userId: user.id }); @@ -87,6 +94,7 @@ export class PublicController { ]); const { activities } = await this.activitiesService.getActivities({ + filters, sortColumn: 'date', sortDirection: 'desc', take: 10, 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 51bc76502..ef6d0ab39 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,17 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; +import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; import { validateObjectForForm } from '@ghostfolio/common/utils'; import { NotificationService } from '@ghostfolio/ui/notifications'; +import { + GfPortfolioFilterFormComponent, + getAssetClassFilters, + getFiltersFromPortfolioFilterFormValue, + getHoldingsForFilter, + getPortfolioFilterFormValue, + getTagFilters +} from '@ghostfolio/ui/portfolio-filter-form'; import { DataService } from '@ghostfolio/ui/services'; import type { HttpErrorResponse } from '@angular/common/http'; @@ -40,6 +51,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; host: { class: 'h-100' }, imports: [ FormsModule, + GfPortfolioFilterFormComponent, MatButtonModule, MatDialogModule, MatFormFieldModule, @@ -52,9 +64,16 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; templateUrl: 'create-or-update-access-dialog.html' }) export class GfCreateOrUpdateAccessDialogComponent implements OnInit { + public accounts: AccountWithPlatform[] = []; + public assetClasses: Filter[] = []; + public holdings: PortfolioPosition[] = []; + public tags: Filter[] = []; + protected accessForm: FormGroup; protected readonly mode: 'create' | 'update'; + private hasExperimentalFeatures = false; + private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly data = @@ -68,17 +87,26 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { private readonly formBuilder = inject(FormBuilder); private readonly notificationService = inject(NotificationService); + private readonly userService = inject(UserService); public constructor() { this.mode = this.data.access ? 'update' : 'create'; } + public get canApplyFilters() { + return ( + this.accessForm?.get('type')?.value === 'PUBLIC' && + this.hasExperimentalFeatures + ); + } + public ngOnInit() { const access = this.data?.access; const isPublic = access?.type === 'PUBLIC'; this.accessForm = this.formBuilder.group({ alias: [access?.alias ?? ''], + filters: [null], granteeUserId: [ access?.grantee ?? null, isPublic ? null : Validators.required @@ -93,6 +121,19 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { ] }); + this.assetClasses = getAssetClassFilters(); + + this.userService + .get() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ accounts, settings, tags }) => { + this.accounts = accounts; + this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false; + this.tags = getTagFilters(tags); + + this.changeDetectorRef.markForCheck(); + }); + this.accessForm .get('type') ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) @@ -102,6 +143,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { if (accessType === 'PRIVATE') { granteeUserIdControl?.setValidators(Validators.required); + this.accessForm.get('filters')?.setValue(null); } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); @@ -114,6 +156,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.changeDetectorRef.markForCheck(); }); + + this.loadHoldings(); } protected onCancel() { @@ -128,9 +172,18 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } } + private buildFilters(): Filter[] { + return getFiltersFromPortfolioFilterFormValue( + this.accessForm.get('filters')?.value + ); + } + private async createAccess() { + const filters = this.buildFilters(); + const access: CreateAccessDto = { alias: this.accessForm.get('alias')?.value, + filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, permissions: [this.accessForm.get('permissions')?.value] }; @@ -164,6 +217,19 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } } + private loadHoldings() { + this.dataService + .fetchPortfolioHoldings() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ holdings }) => { + this.holdings = getHoldingsForFilter(holdings); + + this.updateFiltersFormControl(this.data.access?.settings?.filters); + + this.changeDetectorRef.markForCheck(); + }); + } + private async updateAccess() { const accessId = this.data.access?.id; @@ -171,8 +237,11 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { return; } + const filters = this.buildFilters(); + const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value, + filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, id: accessId, permissions: [this.accessForm.get('permissions')?.value] @@ -206,4 +275,14 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { console.error(error); } } + + private updateFiltersFormControl(filters: Filter[] | undefined) { + if (!filters?.length) { + return; + } + + this.accessForm + .get('filters') + ?.setValue(getPortfolioFilterFormValue(filters, this.holdings)); + } } diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 93614b55a..1736aa9fc 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -59,6 +59,18 @@ } + @if (canApplyFilters) { +

Portfolio Filters

+ + }