diff --git a/CHANGELOG.md b/CHANGELOG.md index 248aaa6ac..7b8705a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a close holding button to the holding detail dialog - Added the _Sponsors_ section to the about page - Extended the user detail dialog in the users section of the admin control panel +- Enable filtering (by account, holdings, tag or asset class) in public access ### Changed diff --git a/apps/api/src/app/access/access-filter.dto.ts b/apps/api/src/app/access/access-filter.dto.ts new file mode 100644 index 000000000..427627063 --- /dev/null +++ b/apps/api/src/app/access/access-filter.dto.ts @@ -0,0 +1,40 @@ +import { DataSource } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested +} from 'class-validator'; + +class HoldingFilterDto { + @IsEnum(DataSource) + dataSource: DataSource; + + @IsString() + symbol: string; +} + +export class AccessFilterDto { + @IsArray() + @IsOptional() + @IsString({ each: true }) + accountIds?: string[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + assetClasses?: string[]; + + @IsArray() + @IsOptional() + @Type(() => HoldingFilterDto) + @ValidateNested({ each: true }) + holdings?: HoldingFilterDto[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + tagIds?: string[]; +} diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index cb1e2d4af..60ec624da 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -1,7 +1,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -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'; @@ -19,7 +19,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Access as AccessModel } from '@prisma/client'; +import { Access as AccessModel, Prisma } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccessService } from './access.service'; @@ -46,22 +46,30 @@ export class AccessController { }); return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions }) => { + ({ + alias, + granteeUser, + id, + permissions: accessPermissions, + settings + }) => { if (granteeUser) { return { alias, - id, - permissions, grantee: granteeUser?.id, + id, + permissions: accessPermissions, + settings: settings as AccessSettings, type: 'PRIVATE' }; } return { alias, - id, - permissions, grantee: 'Public', + id, + permissions: accessPermissions, + settings: settings as AccessSettings, type: 'PUBLIC' }; } @@ -85,12 +93,17 @@ export class AccessController { } try { + const settings: AccessSettings = data.filter + ? { filter: data.filter } + : {}; + return this.accessService.createAccess({ alias: data.alias || undefined, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + settings: settings as Prisma.InputJsonValue, user: { connect: { id: this.request.user.id } } }); } catch { @@ -101,27 +114,6 @@ export class AccessController { } } - @Delete(':id') - @HasPermission(permissions.deleteAccess) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async deleteAccess(@Param('id') id: string): Promise { - const originalAccess = await this.accessService.access({ - id, - userId: this.request.user.id - }); - - if (!originalAccess) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } - - return this.accessService.deleteAccess({ - id - }); - } - @HasPermission(permissions.updateAccess) @Put(':id') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -152,13 +144,18 @@ export class AccessController { } try { + const settings: AccessSettings = data.filter + ? { filter: data.filter } + : {}; + return this.accessService.updateAccess({ data: { alias: data.alias, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, - permissions: data.permissions + permissions: data.permissions, + settings: settings as Prisma.InputJsonValue }, where: { id } }); @@ -169,4 +166,25 @@ export class AccessController { ); } } + + @Delete(':id') + @HasPermission(permissions.deleteAccess) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteAccess(@Param('id') id: string): Promise { + const originalAccess = await this.accessService.access({ + id, + userId: this.request.user.id + }); + + if (!originalAccess) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accessService.deleteAccess({ + id + }); + } } diff --git a/apps/api/src/app/access/create-access.dto.ts b/apps/api/src/app/access/create-access.dto.ts index 087df7183..29d92bb8b 100644 --- a/apps/api/src/app/access/create-access.dto.ts +++ b/apps/api/src/app/access/create-access.dto.ts @@ -1,3 +1,5 @@ +import { AccessFilter } from '@ghostfolio/common/interfaces'; + import { AccessPermission } from '@prisma/client'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -6,6 +8,9 @@ export class CreateAccessDto { @IsString() alias?: string; + @IsOptional() + filter?: AccessFilter; + @IsOptional() @IsUUID() granteeUserId?: string; diff --git a/apps/api/src/app/access/update-access.dto.ts b/apps/api/src/app/access/update-access.dto.ts index 2850186f9..f7c163b0d 100644 --- a/apps/api/src/app/access/update-access.dto.ts +++ b/apps/api/src/app/access/update-access.dto.ts @@ -1,3 +1,5 @@ +import { AccessFilter } from '@ghostfolio/common/interfaces'; + import { AccessPermission } from '@prisma/client'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -6,6 +8,9 @@ export class UpdateAccessDto { @IsString() alias?: string; + @IsOptional() + filter?: AccessFilter; + @IsOptional() @IsUUID() granteeUserId?: string; diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index b09ced4fb..0f1a48a9b 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -8,7 +8,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { getSum } from '@ghostfolio/common/helper'; -import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; +import { + AccessSettings, + Filter, + PublicPortfolioResponse +} from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -20,7 +24,7 @@ import { UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; -import { Type as ActivityType } from '@prisma/client'; +import { Type as ActivityType, AssetSubClass } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -61,6 +65,62 @@ export class PublicController { hasDetails = user.subscription.type === 'Premium'; } + // Get filter configuration from access settings + const accessSettings = (access.settings ?? {}) as AccessSettings; + const accessFilter = accessSettings.filter; + + // Convert access filter to portfolio filters + const portfolioFilters: Filter[] = []; + + if (accessFilter) { + // Add account filters + if (accessFilter.accountIds?.length > 0) { + portfolioFilters.push( + ...accessFilter.accountIds.map((accountId) => ({ + id: accountId, + type: 'ACCOUNT' as const + })) + ); + } + + // Add asset class filters + if (accessFilter.assetClasses?.length > 0) { + portfolioFilters.push( + ...accessFilter.assetClasses.map((assetClass) => ({ + id: assetClass, + type: 'ASSET_CLASS' as const + })) + ); + } + + // Add tag filters + if (accessFilter.tagIds?.length > 0) { + portfolioFilters.push( + ...accessFilter.tagIds.map((tagId) => ({ + id: tagId, + type: 'TAG' as const + })) + ); + } + + // Add holding filters (symbol + dataSource) + // Each holding needs both DATA_SOURCE and SYMBOL filters + if (accessFilter.holdings?.length > 0) { + for (const { dataSource, symbol } of accessFilter.holdings) { + portfolioFilters.push( + { + id: dataSource, + type: 'DATA_SOURCE' as const + }, + { + id: symbol, + type: 'SYMBOL' as const + } + ); + } + } + } + const [ { createdAt, holdings, markets }, { performance: performance1d }, @@ -68,6 +128,7 @@ export class PublicController { { performance: performanceYtd } ] = await Promise.all([ this.portfolioService.getDetails({ + filters: portfolioFilters.length > 0 ? portfolioFilters : undefined, impersonationId: access.userId, userId: user.id, withMarkets: true @@ -75,13 +136,52 @@ export class PublicController { ...['1d', 'max', 'ytd'].map((dateRange) => { return this.portfolioService.getPerformance({ dateRange, + filters: portfolioFilters.length > 0 ? portfolioFilters : undefined, impersonationId: undefined, userId: user.id }); }) ]); + const baseCurrency = + user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; + const filteredHoldings = Object.fromEntries( + Object.entries(holdings).filter(([, holding]) => { + // Remove only cash holdings that match the base currency + const isCash = holding.assetSubClass === AssetSubClass.CASH; + const isBaseCurrency = holding.symbol === baseCurrency; + return !(isCash && isBaseCurrency); + }) + ); + + // Use filters for activities, but exclude DATA_SOURCE/SYMBOL filters + // if there are multiple holdings (the service can't handle multiple symbol filters) + const hasMultipleHoldingFilters = + accessFilter?.holdings && accessFilter.holdings.length > 1; + + const activityFilters = portfolioFilters.filter((filter) => { + // Always include ACCOUNT, ASSET_CLASS, TAG filters + if ( + filter.type === 'ACCOUNT' || + filter.type === 'ASSET_CLASS' || + filter.type === 'TAG' + ) { + return true; + } + + // Include DATA_SOURCE and SYMBOL only if there's a single holding filter + if ( + !hasMultipleHoldingFilters && + (filter.type === 'DATA_SOURCE' || filter.type === 'SYMBOL') + ) { + return true; + } + + return false; + }); + const { activities } = await this.orderService.getOrders({ + filters: activityFilters.length > 0 ? activityFilters : undefined, includeDrafts: false, sortColumn: 'date', sortDirection: 'desc', @@ -92,7 +192,6 @@ export class PublicController { withExcludedAccountsAndActivities: false }); - // Experimental const latestActivities = this.configurationService.get( 'ENABLE_FEATURE_SUBSCRIPTION' ) @@ -129,11 +228,6 @@ export class PublicController { const publicPortfolioResponse: PublicPortfolioResponse = { createdAt, - hasDetails, - latestActivities, - markets, - alias: access.alias, - holdings: {}, performance: { '1d': { relativeChange: @@ -147,23 +241,32 @@ export class PublicController { relativeChange: performanceYtd.netPerformancePercentageWithCurrencyEffect } - } + }, + alias: access.alias, + hasDetails, + holdings: {}, + latestActivities, + markets }; const totalValue = getSum( - Object.values(holdings).map(({ currency, marketPrice, quantity }) => { - return new Big( - this.exchangeRateDataService.toCurrency( - quantity * marketPrice, - currency, - this.request.user?.settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ) - ); - }) + Object.values(filteredHoldings).map( + ({ currency, marketPrice, quantity }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + quantity * marketPrice, + currency, + this.request.user?.settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ) + ); + } + ) ).toNumber(); - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + for (const [symbol, portfolioPosition] of Object.entries( + filteredHoldings + )) { publicPortfolioResponse.holdings[symbol] = { allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, 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 315f86244..50034fbb3 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,8 +1,15 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto'; -import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; +import { + AssetProfileIdentifier, + Filter, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; +import { + GfPortfolioFilterFormComponent, + PortfolioFilterFormValue +} from '@ghostfolio/ui/portfolio-filter-form'; import { ChangeDetectionStrategy, @@ -13,6 +20,7 @@ import { OnInit } from '@angular/core'; import { + AbstractControl, FormBuilder, FormGroup, FormsModule, @@ -28,9 +36,13 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { AccessPermission } from '@prisma/client'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, Subject, catchError, takeUntil } from 'rxjs'; +import { NotificationService } from '../../../core/notification/notification.service'; +import { DataService } from '../../../services/data.service'; +import { validateObjectForForm } from '../../../util/form.util'; import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; @Component({ @@ -38,6 +50,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; host: { class: 'h-100' }, imports: [ FormsModule, + GfPortfolioFilterFormComponent, MatButtonModule, MatDialogModule, MatFormFieldModule, @@ -54,16 +67,22 @@ export class GfCreateOrUpdateAccessDialogComponent { public accessForm: FormGroup; public mode: 'create' | 'update'; + public showFilterPanel = false; + + public accounts: AccountWithPlatform[] = []; + public assetClasses: Filter[] = []; + public holdings: PortfolioPosition[] = []; + public tags: Filter[] = []; private unsubscribeSubject = new Subject(); public constructor( private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, - public dialogRef: MatDialogRef, private dataService: DataService, private formBuilder: FormBuilder, - private notificationService: NotificationService + private notificationService: NotificationService, + public dialogRef: MatDialogRef ) { this.mode = this.data.access?.id ? 'update' : 'create'; } @@ -73,14 +92,20 @@ export class GfCreateOrUpdateAccessDialogComponent this.accessForm = this.formBuilder.group({ alias: [this.data.access.alias], + filters: [null], granteeUserId: [ this.data.access.grantee, - isPublic ? null : Validators.required + isPublic + ? null + : [(control: AbstractControl) => Validators.required(control)] + ], + permissions: [ + this.data.access.permissions[0], + [(control: AbstractControl) => Validators.required(control)] ], - permissions: [this.data.access.permissions[0], Validators.required], type: [ { disabled: this.mode === 'update', value: this.data.access.type }, - Validators.required + [(control: AbstractControl) => Validators.required(control)] ] }); @@ -89,17 +114,28 @@ export class GfCreateOrUpdateAccessDialogComponent const permissionsControl = this.accessForm.get('permissions'); if (accessType === 'PRIVATE') { - granteeUserIdControl.setValidators(Validators.required); + granteeUserIdControl.setValidators([ + (control: AbstractControl) => Validators.required(control) + ]); + this.showFilterPanel = false; + this.accessForm.get('filters')?.setValue(null); } else { granteeUserIdControl.clearValidators(); granteeUserIdControl.setValue(null); permissionsControl.setValue(this.data.access.permissions[0]); + this.showFilterPanel = true; + this.loadFilterData(); } granteeUserIdControl.updateValueAndValidity(); this.changeDetectorRef.markForCheck(); }); + + if (isPublic) { + this.showFilterPanel = true; + this.loadFilterData(); + } } public onCancel() { @@ -119,11 +155,164 @@ export class GfCreateOrUpdateAccessDialogComponent this.unsubscribeSubject.complete(); } + private buildFilterObject(): + | { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: AssetProfileIdentifier[]; + tagIds?: string[]; + } + | undefined { + const filterValue = this.accessForm.get('filters') + ?.value as PortfolioFilterFormValue | null; + + if ( + !filterValue || + (!filterValue.account && + !filterValue.assetClass && + !filterValue.holding && + !filterValue.tag) + ) { + return undefined; + } + + const filter: { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: AssetProfileIdentifier[]; + tagIds?: string[]; + } = {}; + + if (filterValue.account) { + filter.accountIds = [filterValue.account]; + } + + if (filterValue.assetClass) { + filter.assetClasses = [filterValue.assetClass]; + } + + if (filterValue.holding) { + filter.holdings = [ + { + dataSource: filterValue.holding.dataSource, + symbol: filterValue.holding.symbol + } + ]; + } + + if (filterValue.tag) { + filter.tagIds = [filterValue.tag]; + } + + return filter; + } + + private loadFilterData() { + const existingFilter = this.data.access.settings?.filter; + + this.dataService + .fetchAccounts() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((response) => { + this.accounts = response.accounts; + this.updateFiltersFormControl(existingFilter); + }); + + this.dataService + .fetchPortfolioDetails({}) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((response) => { + if (response.holdings) { + this.holdings = Object.values(response.holdings); + + const assetClassesSet = new Set(); + Object.values(response.holdings).forEach((holding) => { + if (holding.assetClass) { + assetClassesSet.add(holding.assetClass); + } + }); + this.assetClasses = Array.from(assetClassesSet).map((ac) => ({ + id: ac, + label: ac, + type: 'ASSET_CLASS' as const + })); + + this.updateFiltersFormControl(existingFilter); + } + this.changeDetectorRef.markForCheck(); + }); + + this.dataService + .fetchTags() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((response) => { + this.tags = response.map((tag) => ({ + id: tag.id, + label: tag.name, + type: 'TAG' as const + })); + + this.updateFiltersFormControl(existingFilter); + this.changeDetectorRef.markForCheck(); + }); + } + + private updateFiltersFormControl( + existingFilter: + | { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: AssetProfileIdentifier[]; + tagIds?: string[]; + } + | undefined + ) { + if (!existingFilter) { + return; + } + + const filterValue: Partial = {}; + + if (existingFilter.accountIds?.[0] && this.accounts.length > 0) { + filterValue.account = existingFilter.accountIds[0]; + } + + if (existingFilter.assetClasses?.[0] && this.assetClasses.length > 0) { + filterValue.assetClass = existingFilter.assetClasses[0]; + } + + if (existingFilter.holdings?.[0] && this.holdings.length > 0) { + const holdingData = existingFilter.holdings[0]; + const holding = this.holdings.find( + (h) => + h.dataSource === holdingData.dataSource && + h.symbol === holdingData.symbol + ); + if (holding) { + filterValue.holding = holding; + } + } + + if (existingFilter.tagIds?.[0] && this.tags.length > 0) { + filterValue.tag = existingFilter.tagIds[0]; + } + + if (Object.keys(filterValue).length > 0) { + this.accessForm.get('filters')?.setValue(filterValue); + this.changeDetectorRef.markForCheck(); + } + } + private async createAccess() { + const filter = this.showFilterPanel ? this.buildFilterObject() : undefined; + const access: CreateAccessDto = { - alias: this.accessForm.get('alias').value, - granteeUserId: this.accessForm.get('granteeUserId').value, - permissions: [this.accessForm.get('permissions').value] + alias: this.accessForm.get('alias')?.value as string, + filter: filter, + granteeUserId: this.accessForm.get('granteeUserId')?.value as string, + permissions: [ + this.accessForm.get('permissions')?.value as AccessPermission + ] }; try { @@ -136,8 +325,8 @@ export class GfCreateOrUpdateAccessDialogComponent this.dataService .postAccess(access) .pipe( - catchError((error) => { - if (error.status === StatusCodes.BAD_REQUEST) { + catchError((error: { status?: number }) => { + if (error.status === (StatusCodes.BAD_REQUEST as number)) { this.notificationService.alert({ title: $localize`Oops! Could not grant access.` }); @@ -156,11 +345,16 @@ export class GfCreateOrUpdateAccessDialogComponent } private async updateAccess() { + const filter = this.showFilterPanel ? this.buildFilterObject() : undefined; + const access: UpdateAccessDto = { - alias: this.accessForm.get('alias').value, - granteeUserId: this.accessForm.get('granteeUserId').value, + alias: this.accessForm.get('alias')?.value as string, + filter: filter, + granteeUserId: this.accessForm.get('granteeUserId')?.value as string, id: this.data.access.id, - permissions: [this.accessForm.get('permissions').value] + permissions: [ + this.accessForm.get('permissions')?.value as AccessPermission + ] }; try { @@ -173,8 +367,8 @@ export class GfCreateOrUpdateAccessDialogComponent this.dataService .putAccess(access) .pipe( - catchError(({ status }) => { - if (status.status === StatusCodes.BAD_REQUEST) { + catchError((error: { status?: number }) => { + if (error.status === (StatusCodes.BAD_REQUEST as number)) { this.notificationService.alert({ title: $localize`Oops! Could not update access.` }); 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 11669041d..0e5fe097b 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,24 @@ } + + @if (showFilterPanel) { +
+

Filter Settings (Optional)

+

+ Configure which accounts, holdings, tags, and asset classes will be + visible in this public access. Leave empty to show all data. +

+ + +
+ }