Browse Source

Feature/public access filtering (#7146)

* Add filtering for public access

* Update changelog

---------

Co-authored-by: Germán Martín <github@gmartin.net>
pull/7145/merge
Thomas Kaul 17 hours ago
committed by GitHub
parent
commit
05281d5980
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 10
      apps/api/src/app/access/access.controller.ts
  3. 7
      apps/api/src/app/access/access.service.ts
  4. 10
      apps/api/src/app/endpoints/public/public.controller.ts
  5. 79
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  6. 12
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  7. 1
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  8. 8
      libs/common/src/lib/dtos/create-access.dto.ts
  9. 8
      libs/common/src/lib/dtos/update-access.dto.ts
  10. 5
      libs/common/src/lib/interfaces/access-settings.interface.ts
  11. 3
      libs/common/src/lib/interfaces/access.interface.ts
  12. 2
      libs/common/src/lib/interfaces/index.ts
  13. 128
      libs/ui/src/lib/assistant/assistant.component.ts
  14. 1
      libs/ui/src/lib/portfolio-filter-form/index.ts
  15. 122
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts

4
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

10
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 }
});

7
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<Access> {
return this.prismaService.access.create({
data

10
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,

79
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));
}
}

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

@ -59,6 +59,18 @@
</mat-form-field>
</div>
}
@if (canApplyFilters) {
<h2 class="h6" i18n>Portfolio Filters</h2>
<gf-portfolio-filter-form
appearance="outline"
class="w-100"
formControlName="filters"
[accounts]="accounts"
[assetClasses]="assetClasses"
[holdings]="holdings"
[tags]="tags"
/>
}
</div>
<div class="justify-content-end" mat-dialog-actions>
<button mat-button type="button" (click)="onCancel()">

1
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -229,6 +229,7 @@ export class GfUserAccountAccessComponent implements OnInit {
grantee: access.grantee === 'Public' ? undefined : access.grantee,
id: access.id,
permissions: access.permissions,
settings: access.settings,
type: access.type
}
} satisfies CreateOrUpdateAccessDialogParams,

8
libs/common/src/lib/dtos/create-access.dto.ts

@ -1,11 +1,17 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsArray()
@IsOptional()
filters?: Filter[];
@IsOptional()
@IsUUID()
granteeUserId?: string;

8
libs/common/src/lib/dtos/update-access.dto.ts

@ -1,11 +1,17 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsArray()
@IsOptional()
filters?: Filter[];
@IsOptional()
@IsUUID()
granteeUserId?: string;

5
libs/common/src/lib/interfaces/access-settings.interface.ts

@ -0,0 +1,5 @@
import { Filter } from './filter.interface';
export interface AccessSettings {
filters?: Filter[];
}

3
libs/common/src/lib/interfaces/access.interface.ts

@ -2,10 +2,13 @@ import { AccessType } from '@ghostfolio/common/types';
import { AccessPermission } from '@prisma/client';
import { AccessSettings } from './access-settings.interface';
export interface Access {
alias?: string;
grantee?: string;
id: string;
permissions: AccessPermission[];
settings?: AccessSettings;
type: AccessType;
}

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

@ -1,3 +1,4 @@
import type { AccessSettings } from './access-settings.interface';
import type { Access } from './access.interface';
import type { AccountBalance } from './account-balance.interface';
import type { Activity, ActivityError } from './activities.interface';
@ -101,6 +102,7 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface';
export {
Access,
AccessSettings,
AccessTokenResponse,
AccountBalance,
AccountBalancesResponse,

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

@ -1,4 +1,3 @@
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
@ -31,7 +30,6 @@ import { MatMenuTrigger } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { AssetClass, DataSource } from '@prisma/client';
import { differenceInYears, eachYearOfInterval, format } from 'date-fns';
import Fuse from 'fuse.js';
import { addIcons } from 'ionicons';
@ -56,7 +54,12 @@ import {
import { translate } from '../i18n';
import {
GfPortfolioFilterFormComponent,
PortfolioFilterFormValue
PortfolioFilterFormValue,
getAssetClassFilters,
getFiltersFromPortfolioFilterFormValue,
getHoldingsForFilter,
getPortfolioFilterFormValue,
getTagFilters
} from '../portfolio-filter-form';
import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component';
import { SearchMode } from './enums/search-mode';
@ -194,17 +197,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}
public ngOnInit() {
this.assetClasses = Object.keys(AssetClass)
.map((assetClass) => {
return {
id: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
} satisfies Filter;
})
.sort((a, b) => {
return a.label.localeCompare(b.label);
});
this.assetClasses = getAssetClassFilters();
this.searchFormControl.valueChanges
.pipe(
@ -437,21 +430,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.portfolioFilterFormControl.disable({ emitEvent: false });
}
this.tags =
this.user?.tags
?.filter(({ isUsed }) => {
return isUsed;
})
?.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
} satisfies Filter;
})
?.sort((a, b) => {
return a.label.localeCompare(b.label);
}) ?? [];
this.tags = getTagFilters(this.user?.tags);
}
public initialize() {
@ -500,18 +479,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.fetchPortfolioHoldings()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = holdings
.filter(({ assetProfile }) => {
return (
assetProfile.assetSubClass &&
!['CASH'].includes(assetProfile.assetSubClass)
);
})
.sort((a, b) => {
return (a.assetProfile.name ?? '').localeCompare(
b.assetProfile.name ?? ''
);
});
this.holdings = getHoldingsForFilter(holdings);
this.setPortfolioFilterFormValues();
@ -520,30 +488,12 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}
public onApplyFilters() {
const filterValue = this.portfolioFilterFormControl.value;
this.filtersChanged.emit([
{
id: filterValue?.account ?? '',
type: 'ACCOUNT'
},
{
id: filterValue?.assetClass ?? '',
type: 'ASSET_CLASS'
},
{
id: filterValue?.holding?.assetProfile?.dataSource ?? '',
type: 'DATA_SOURCE'
},
{
id: filterValue?.holding?.assetProfile?.symbol ?? '',
type: 'SYMBOL'
},
{
id: filterValue?.tag ?? '',
type: 'TAG'
}
]);
this.filtersChanged.emit(
getFiltersFromPortfolioFilterFormValue(
this.portfolioFilterFormControl.value,
{ includeEmpty: true }
)
);
this.onCloseAssistant();
}
@ -769,25 +719,35 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}
private setPortfolioFilterFormValues() {
const dataSource = this.user?.settings?.[
'filters.dataSource'
] as DataSource;
const symbol = this.user?.settings?.['filters.symbol'];
const selectedHolding = this.holdings.find((holding) => {
return (
!!(dataSource && symbol) &&
getAssetProfileIdentifier({
dataSource: holding.assetProfile.dataSource,
symbol: holding.assetProfile.symbol
}) === getAssetProfileIdentifier({ dataSource, symbol })
);
});
const settings = this.user?.settings;
this.portfolioFilterFormControl.setValue({
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
holding: selectedHolding ?? null,
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
});
const filters = [
{
id: settings?.['filters.accounts']?.[0] ?? '',
type: 'ACCOUNT'
},
{
id: settings?.['filters.assetClasses']?.[0] ?? '',
type: 'ASSET_CLASS'
},
{
id: settings?.['filters.dataSource'] ?? '',
type: 'DATA_SOURCE'
},
{
id: settings?.['filters.symbol'] ?? '',
type: 'SYMBOL'
},
{
id: settings?.['filters.tags']?.[0] ?? '',
type: 'TAG'
}
].filter(({ id }) => {
return !!id;
}) as Filter[];
this.portfolioFilterFormControl.setValue(
getPortfolioFilterFormValue(filters, this.holdings)
);
}
}

1
libs/ui/src/lib/portfolio-filter-form/index.ts

@ -1,2 +1,3 @@
export * from './interfaces';
export * from './portfolio-filter-form.component';
export * from './portfolio-filter-form.util';

122
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts

@ -0,0 +1,122 @@
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AssetClass, DataSource, Tag } from '@prisma/client';
import { translate } from '../i18n';
import { PortfolioFilterFormValue } from './interfaces';
export function getAssetClassFilters(): Filter[] {
return Object.keys(AssetClass)
.map((assetClass) => {
return {
id: assetClass,
label: translate(assetClass),
type: 'ASSET_CLASS'
} satisfies Filter;
})
.sort((a, b) => {
return a.label.localeCompare(b.label);
});
}
export function getFiltersFromPortfolioFilterFormValue(
value: PortfolioFilterFormValue | null,
{ includeEmpty = false }: { includeEmpty?: boolean } = {}
) {
const filters: Filter[] = [
{
id: value?.account ?? '',
type: 'ACCOUNT'
},
{
id: value?.assetClass ?? '',
type: 'ASSET_CLASS'
},
{
id: value?.holding?.assetProfile?.dataSource ?? '',
type: 'DATA_SOURCE'
},
{
id: value?.holding?.assetProfile?.symbol ?? '',
type: 'SYMBOL'
},
{
id: value?.tag ?? '',
type: 'TAG'
}
];
return includeEmpty
? filters
: filters.filter(({ id }) => {
return !!id;
});
}
export function getHoldingsForFilter(holdings: PortfolioPosition[] = []) {
return holdings
.filter(({ assetProfile }) => {
return (
assetProfile.assetSubClass &&
!['CASH'].includes(assetProfile.assetSubClass)
);
})
.sort((a, b) => {
return (a.assetProfile.name ?? '').localeCompare(
b.assetProfile.name ?? ''
);
});
}
export function getPortfolioFilterFormValue(
filters: Filter[],
holdings: PortfolioPosition[]
): PortfolioFilterFormValue {
const getFilterId = (type: Filter['type']) => {
return (
filters?.find((filter) => {
return filter.type === type;
})?.id ?? null
);
};
const dataSource = getFilterId('DATA_SOURCE') as DataSource;
const symbol = getFilterId('SYMBOL');
const holding = holdings.find(({ assetProfile }) => {
return (
!!(dataSource && symbol) &&
getAssetProfileIdentifier(assetProfile) ===
getAssetProfileIdentifier({ dataSource, symbol })
);
});
return {
account: getFilterId('ACCOUNT'),
assetClass: getFilterId('ASSET_CLASS'),
holding: holding ?? null,
tag: getFilterId('TAG')
};
}
export function getTagFilters(
tags: (Tag & { isUsed: boolean })[] = []
): Filter[] {
return (
tags
?.filter(({ isUsed }) => {
return isUsed;
})
?.map(({ id, name }) => {
return {
id,
label: translate(name),
type: 'TAG'
} satisfies Filter;
})
?.sort((a, b) => {
return a.label.localeCompare(b.label);
}) ?? []
);
}
Loading…
Cancel
Save